diff --git a/projects/js-packages/publicize-components/changelog/social-unified-connections-management b/projects/js-packages/publicize-components/changelog/social-unified-connections-management new file mode 100644 index 0000000000000..03f5d101125bf --- /dev/null +++ b/projects/js-packages/publicize-components/changelog/social-unified-connections-management @@ -0,0 +1,4 @@ +Significance: minor +Type: changed + +Social | Unify connections management API schema diff --git a/projects/js-packages/publicize-components/src/components/connection-management/connection-info.tsx b/projects/js-packages/publicize-components/src/components/connection-management/connection-info.tsx index ebd65c1ccea8a..8b4f1bf73b6f5 100644 --- a/projects/js-packages/publicize-components/src/components/connection-management/connection-info.tsx +++ b/projects/js-packages/publicize-components/src/components/connection-management/connection-info.tsx @@ -27,7 +27,7 @@ export function ConnectionInfo( { connection, service }: ConnectionInfoProps ) {
diff --git a/projects/js-packages/publicize-components/src/components/connection-management/connection-name.tsx b/projects/js-packages/publicize-components/src/components/connection-management/connection-name.tsx index 7bdd740496e47..d28420646ea29 100644 --- a/projects/js-packages/publicize-components/src/components/connection-management/connection-name.tsx +++ b/projects/js-packages/publicize-components/src/components/connection-management/connection-name.tsx @@ -27,12 +27,10 @@ export function ConnectionName( { connection }: ConnectionNameProps ) { return (
{ ! connection.profile_link ? ( - - { connection.display_name || connection.external_name } - + { connection.display_name } ) : ( - { connection.display_name || connection.external_display || connection.external_name } + { connection.display_name } ) } { isUpdating ? ( diff --git a/projects/js-packages/publicize-components/src/components/connection-management/disconnect.tsx b/projects/js-packages/publicize-components/src/components/connection-management/disconnect.tsx index ccc50575a39d9..e804757301458 100644 --- a/projects/js-packages/publicize-components/src/components/connection-management/disconnect.tsx +++ b/projects/js-packages/publicize-components/src/components/connection-management/disconnect.tsx @@ -30,15 +30,16 @@ export function Disconnect( { const { deleteConnectionById } = useDispatch( socialStore ); - const { isDisconnecting } = useSelect( + const { isDisconnecting, canManageConnection } = useSelect( select => { - const { getDeletingConnections } = select( socialStore ); + const { getDeletingConnections, canUserManageConnection } = select( socialStore ); return { isDisconnecting: getDeletingConnections().includes( connection.connection_id ), + canManageConnection: canUserManageConnection( connection ), }; }, - [ connection.connection_id ] + [ connection ] ); const onClickDisconnect = useCallback( async () => { @@ -49,7 +50,7 @@ export function Disconnect( { } ); }, [ connection.connection_id, deleteConnectionById ] ); - if ( ! connection.can_disconnect ) { + if ( ! canManageConnection ) { return null; } diff --git a/projects/js-packages/publicize-components/src/components/connection-management/reconnect.tsx b/projects/js-packages/publicize-components/src/components/connection-management/reconnect.tsx index 22bf30e2e64d2..a3e50e1ef5200 100644 --- a/projects/js-packages/publicize-components/src/components/connection-management/reconnect.tsx +++ b/projects/js-packages/publicize-components/src/components/connection-management/reconnect.tsx @@ -24,15 +24,16 @@ export function Reconnect( { connection, service, variant = 'link' }: ReconnectP const { deleteConnectionById, setKeyringResult, openConnectionsModal, setReconnectingAccount } = useDispatch( socialStore ); - const { isDisconnecting } = useSelect( + const { isDisconnecting, canManageConnection } = useSelect( select => { - const { getDeletingConnections } = select( socialStore ); + const { getDeletingConnections, canUserManageConnection } = select( socialStore ); return { isDisconnecting: getDeletingConnections().includes( connection.connection_id ), + canManageConnection: canUserManageConnection( connection ), }; }, - [ connection.connection_id ] + [ connection ] ); const onConfirm = useCallback( @@ -63,7 +64,7 @@ export function Reconnect( { connection, service, variant = 'link' }: ReconnectP const formData = new FormData(); if ( service.ID === 'mastodon' ) { - formData.set( 'instance', connection.external_display ); + formData.set( 'instance', connection.external_handle ); } if ( service.ID === 'bluesky' ) { @@ -80,7 +81,7 @@ export function Reconnect( { connection, service, variant = 'link' }: ReconnectP setReconnectingAccount, ] ); - if ( ! connection.can_disconnect ) { + if ( ! canManageConnection ) { return null; } diff --git a/projects/js-packages/publicize-components/src/components/connection-management/tests/specs/disconnect.test.js b/projects/js-packages/publicize-components/src/components/connection-management/tests/specs/disconnect.test.js index 0bb54b87314ba..52a8dbc52a9cf 100644 --- a/projects/js-packages/publicize-components/src/components/connection-management/tests/specs/disconnect.test.js +++ b/projects/js-packages/publicize-components/src/components/connection-management/tests/specs/disconnect.test.js @@ -38,7 +38,6 @@ describe( 'Disconnecting a connection', () => { service_name: 'facebook', connection_id: '2', display_name: 'Facebook', - can_disconnect: true, } } /> ); diff --git a/projects/js-packages/publicize-components/src/components/connection-management/tests/specs/mark-as-shared.test.js b/projects/js-packages/publicize-components/src/components/connection-management/tests/specs/mark-as-shared.test.js index cb946d29b2ad5..111c88a4b1c37 100644 --- a/projects/js-packages/publicize-components/src/components/connection-management/tests/specs/mark-as-shared.test.js +++ b/projects/js-packages/publicize-components/src/components/connection-management/tests/specs/mark-as-shared.test.js @@ -27,7 +27,6 @@ describe( 'Marking a connection as shared', () => { service_name: 'facebook', connection_id: '2', display_name: 'Facebook', - can_disconnect: true, } } /> ); diff --git a/projects/js-packages/publicize-components/src/components/connection-management/tests/specs/reconnect.test.js b/projects/js-packages/publicize-components/src/components/connection-management/tests/specs/reconnect.test.js index ca471f52581ed..c6eb17f78f62d 100644 --- a/projects/js-packages/publicize-components/src/components/connection-management/tests/specs/reconnect.test.js +++ b/projects/js-packages/publicize-components/src/components/connection-management/tests/specs/reconnect.test.js @@ -17,8 +17,7 @@ describe( 'Reconnect', () => { const mockConnection = { connection_id: '123', - can_disconnect: true, - external_display: 'mockDisplay', + display_name: 'mockDisplay', }; beforeEach( () => { @@ -58,12 +57,8 @@ describe( 'Reconnect', () => { } ); test( 'does not render the button if connection cannot be disconnected', () => { - const nonDisconnectableConnection = { - ...mockConnection, - can_disconnect: false, - }; - - render( ); + setup( { canUserManageConnection: false } ); + render( ); expect( screen.queryByRole( 'button' ) ).not.toBeInTheDocument(); } ); diff --git a/projects/js-packages/publicize-components/src/components/connection/index.js b/projects/js-packages/publicize-components/src/components/connection/index.js index b8601613d87b3..03d643a55d575 100644 --- a/projects/js-packages/publicize-components/src/components/connection/index.js +++ b/projects/js-packages/publicize-components/src/components/connection/index.js @@ -62,7 +62,7 @@ class PublicizeConnection extends Component { } isDisabled() { - return this.props.disabled || this.connectionIsFailing() || this.connectionNeedsReauth(); + return this.props.disabled || this.connectionIsFailing(); } render() { diff --git a/projects/js-packages/publicize-components/src/components/form/broken-connections-notice.tsx b/projects/js-packages/publicize-components/src/components/form/broken-connections-notice.tsx index ffb87494effae..fc142cfe131c3 100644 --- a/projects/js-packages/publicize-components/src/components/form/broken-connections-notice.tsx +++ b/projects/js-packages/publicize-components/src/components/form/broken-connections-notice.tsx @@ -1,30 +1,18 @@ import { Button } from '@automattic/jetpack-components'; import { ExternalLink } from '@wordpress/components'; -import { useDispatch } from '@wordpress/data'; +import { useDispatch, useSelect } from '@wordpress/data'; import { createInterpolateElement, Fragment } from '@wordpress/element'; import { __, _x } from '@wordpress/i18n'; import usePublicizeConfig from '../../hooks/use-publicize-config'; -import useSocialMediaConnections from '../../hooks/use-social-media-connections'; import { store } from '../../social-store'; import { Connection } from '../../social-store/types'; -import { checkConnectionCode } from '../../utils/connections'; import { getSocialScriptData } from '../../utils/script-data'; import Notice from '../notice'; import { useServiceLabel } from '../services/use-service-label'; import styles from './styles.module.scss'; export const BrokenConnectionsNotice: React.FC = () => { - const { connections } = useSocialMediaConnections(); - - const brokenConnections = connections.filter( connection => { - return ( - connection.status === 'broken' || - // This is a legacy check for connections that are not healthy. - // TODO remove this check when we are sure that all connections have - // the status property (same schema for connections endpoints), e.g. on Simple/Atomic sites - checkConnectionCode( connection, 'broken' ) - ); - } ); + const brokenConnections = useSelect( select => select( store ).getBrokenConnections(), [] ); const { connectionsPageUrl } = usePublicizeConfig(); @@ -87,11 +75,9 @@ export const BrokenConnectionsNotice: React.FC = () => { { // Since Intl.ListFormat is not allowed in Jetpack yet, // we join the connections with a comma and space - connectionsList.map( ( { display_name, external_display, id }, i ) => ( - - - { display_name || external_display } - + connectionsList.map( ( { display_name, connection_id }, i ) => ( + + { display_name } { i < connectionsList.length - 1 && _x( ',', diff --git a/projects/js-packages/publicize-components/src/components/form/connections-list.tsx b/projects/js-packages/publicize-components/src/components/form/connections-list.tsx index c2c5e5c51b4dc..e15950ad91771 100644 --- a/projects/js-packages/publicize-components/src/components/form/connections-list.tsx +++ b/projects/js-packages/publicize-components/src/components/form/connections-list.tsx @@ -35,18 +35,17 @@ export const ConnectionsList: React.FC = () => {
    { connections.map( conn => { - const { display_name, id, service_name, profile_picture, connection_id } = conn; - const currentId = connection_id ? connection_id : id; + const { display_name, service_name, profile_picture, connection_id } = conn; return ( ); diff --git a/projects/js-packages/publicize-components/src/components/form/unsupported-connections-notice.tsx b/projects/js-packages/publicize-components/src/components/form/unsupported-connections-notice.tsx index fd64817250ccc..778db75f9b53f 100644 --- a/projects/js-packages/publicize-components/src/components/form/unsupported-connections-notice.tsx +++ b/projects/js-packages/publicize-components/src/components/form/unsupported-connections-notice.tsx @@ -3,16 +3,20 @@ import { createInterpolateElement } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { usePublicizeConfig } from '../../..'; import useSocialMediaConnections from '../../hooks/use-social-media-connections'; -import { checkConnectionCode } from '../../utils/connections'; import Notice from '../notice'; +import { useService } from '../services/use-service'; export const UnsupportedConnectionsNotice: React.FC = () => { const { connections } = useSocialMediaConnections(); const { connectionsPageUrl } = usePublicizeConfig(); - const unsupportedConnections = connections.filter( connection => - checkConnectionCode( connection, 'unsupported' ) + const getServices = useService(); + + const unsupportedConnections = connections.filter( + connection => + // If getServices returns falsy, it means the service is unsupported. + ! getServices( connection.service_name ) ); return ( diff --git a/projects/js-packages/publicize-components/src/components/form/use-connection-state.ts b/projects/js-packages/publicize-components/src/components/form/use-connection-state.ts index e994363e19d4a..7f3a5bc4daaa6 100644 --- a/projects/js-packages/publicize-components/src/components/form/use-connection-state.ts +++ b/projects/js-packages/publicize-components/src/components/form/use-connection-state.ts @@ -31,17 +31,16 @@ export const useConnectionState = () => { */ const isInGoodShape = useCallback( ( connection: Connection ) => { - const { id, is_healthy, connection_id, status } = connection; - const currentId = connection_id ? connection_id : id; + const { connection_id: id, status } = connection; // 1. Be healthy - const isHealthy = false !== is_healthy && status !== 'broken'; + const isHealthy = status !== 'broken'; // 2. Have no validation errors - const hasValidationErrors = validationErrors[ currentId ] !== undefined && ! isConvertible; + const hasValidationErrors = validationErrors[ id ] !== undefined && ! isConvertible; // 3. Not have a NO_MEDIA_ERROR when media is required - const hasNoMediaError = validationErrors[ currentId ] === NO_MEDIA_ERROR; + const hasNoMediaError = validationErrors[ id ] === NO_MEDIA_ERROR; return isHealthy && ! hasValidationErrors && ! hasNoMediaError; }, diff --git a/projects/js-packages/publicize-components/src/components/manage-connections-modal/confirmation-form/index.tsx b/projects/js-packages/publicize-components/src/components/manage-connections-modal/confirmation-form/index.tsx index f99e92f74e942..a07f892d38a56 100644 --- a/projects/js-packages/publicize-components/src/components/manage-connections-modal/confirmation-form/index.tsx +++ b/projects/js-packages/publicize-components/src/components/manage-connections-modal/confirmation-form/index.tsx @@ -162,7 +162,7 @@ export function ConfirmationForm( { keyringResult, onComplete, isAdmin }: Confir display_name: accountInfo?.label, profile_picture: accountInfo?.profile_picture, service_name: service.ID, - external_id: external_user_ID, + external_id: external_user_ID.toString(), } ); onComplete(); diff --git a/projects/js-packages/publicize-components/src/components/services/connect-form.tsx b/projects/js-packages/publicize-components/src/components/services/connect-form.tsx index a700da6a8514a..a530834148449 100644 --- a/projects/js-packages/publicize-components/src/components/services/connect-form.tsx +++ b/projects/js-packages/publicize-components/src/components/services/connect-form.tsx @@ -24,7 +24,7 @@ type ConnectFormProps = { * * @param {ConnectFormProps} props - Component props * - * @return {import('react').ReactNode} Connect form component + * @return Connect form component */ export function ConnectForm( { service, diff --git a/projects/js-packages/publicize-components/src/components/services/custom-inputs.tsx b/projects/js-packages/publicize-components/src/components/services/custom-inputs.tsx index 90f61a1350056..e2f114f412ba4 100644 --- a/projects/js-packages/publicize-components/src/components/services/custom-inputs.tsx +++ b/projects/js-packages/publicize-components/src/components/services/custom-inputs.tsx @@ -73,7 +73,7 @@ export function CustomInputs( { service }: CustomInputsProps ) { name="handle" defaultValue={ reconnectingAccount?.service_name === 'bluesky' - ? reconnectingAccount?.external_name + ? reconnectingAccount?.external_handle : undefined } autoComplete="off" diff --git a/projects/js-packages/publicize-components/src/components/services/service-connection-info.tsx b/projects/js-packages/publicize-components/src/components/services/service-connection-info.tsx index ca3075a16f584..7e5bf2c806391 100644 --- a/projects/js-packages/publicize-components/src/components/services/service-connection-info.tsx +++ b/projects/js-packages/publicize-components/src/components/services/service-connection-info.tsx @@ -1,5 +1,7 @@ import { IconTooltip, Text } from '@automattic/jetpack-components'; +import { useSelect } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; +import { store as socialStore } from '../../social-store'; import { Connection } from '../../social-store/types'; import { ConnectionName } from '../connection-management/connection-name'; import { ConnectionStatus } from '../connection-management/connection-status'; @@ -19,6 +21,11 @@ export const ServiceConnectionInfo = ( { service, isAdmin, }: ServiceConnectionInfoProps ) => { + const canManageConnection = useSelect( + select => select( socialStore ).canUserManageConnection( connection ), + [ connection ] + ); + return (
    @@ -40,7 +47,7 @@ export const ServiceConnectionInfo = ( { * if the user can disconnect the connection. * Otherwise, non-admin authors will see only the status without any further context. */ - if ( conn.status === 'broken' && conn.can_disconnect ) { + if ( conn.status === 'broken' && canManageConnection ) { return ; } @@ -63,7 +70,7 @@ export const ServiceConnectionInfo = ( { * Now if the user is not an admin, we tell them that the connection * was added by an admin and show the connection status if it's broken. */ - return ! conn.can_disconnect ? ( + return ! canManageConnection ? ( <> { __( diff --git a/projects/js-packages/publicize-components/src/components/services/service-item.tsx b/projects/js-packages/publicize-components/src/components/services/service-item.tsx index 2ab25c5163801..56e2322786610 100644 --- a/projects/js-packages/publicize-components/src/components/services/service-item.tsx +++ b/projects/js-packages/publicize-components/src/components/services/service-item.tsx @@ -1,8 +1,10 @@ import { Button, useBreakpointMatch } from '@automattic/jetpack-components'; import { Panel, PanelBody } from '@wordpress/components'; +import { useSelect } from '@wordpress/data'; import { useEffect, useReducer, useRef } from '@wordpress/element'; import { __, _x } from '@wordpress/i18n'; import { Icon, chevronDown, chevronUp } from '@wordpress/icons'; +import { store as socialStore } from '../../social-store'; import { ConnectForm } from './connect-form'; import { ServiceItemDetails, ServicesItemDetailsProps } from './service-item-details'; import { ServiceStatus } from './service-status'; @@ -40,8 +42,13 @@ export function ServiceItem( { const brokenConnections = serviceConnections.filter( ( { status } ) => status === 'broken' ); - const hasOwnBrokenConnections = brokenConnections.some( - ( { can_disconnect } ) => can_disconnect + const hasOwnBrokenConnections = useSelect( + select => { + const { canUserManageConnection } = select( socialStore ); + + return brokenConnections.some( canUserManageConnection ); + }, + [ brokenConnections ] ); const hideInitialConnectForm = diff --git a/projects/js-packages/publicize-components/src/components/services/service-status.tsx b/projects/js-packages/publicize-components/src/components/services/service-status.tsx index 0620edc6231ba..f21a5f97b77e3 100644 --- a/projects/js-packages/publicize-components/src/components/services/service-status.tsx +++ b/projects/js-packages/publicize-components/src/components/services/service-status.tsx @@ -1,5 +1,7 @@ import { Alert } from '@automattic/jetpack-components'; +import { useSelect } from '@wordpress/data'; import { __, _n, sprintf } from '@wordpress/i18n'; +import { store as socialStore } from '../../social-store'; import { Connection } from '../../social-store/types'; import styles from './style.module.scss'; @@ -16,13 +18,16 @@ export type ServiceStatusProps = { * @return {import('react').ReactNode} Service status component */ export function ServiceStatus( { serviceConnections, brokenConnections }: ServiceStatusProps ) { + const canFix = useSelect( + select => brokenConnections.some( select( socialStore ).canUserManageConnection ), + [ brokenConnections ] + ); + if ( ! serviceConnections.length ) { return null; } if ( brokenConnections.length > 0 ) { - const canFix = brokenConnections.some( ( { can_disconnect } ) => can_disconnect ); - return ( ( { @@ -19,13 +20,22 @@ describe( 'ServiceConnectionInfo', () => { profile_picture: 'https://example.com/profile.jpg', display_name: 'Example User', status: 'connected', - can_disconnect: true, }; const service = { icon: () => , }; + beforeAll( () => { + global.JetpackScriptData = { + user: { + current_user: { + id: 123, + }, + }, + }; + } ); + const renderComponent = ( connOverrides = {}, serviceOverrides = {}, props = {} ) => { render( { ); }; + afterEach( () => { + jest.clearAllMocks(); + } ); + test( 'renders profile picture if available', () => { renderComponent(); const profilePic = screen.getByAltText( 'Example User' ); @@ -70,7 +84,9 @@ describe( 'ServiceConnectionInfo', () => { } ); test( 'displays description if connection cannot be disconnected', () => { - renderComponent( { can_disconnect: false } ); + setup( { canUserManageConnection: false } ); + renderComponent(); + expect( screen.getByText( 'This connection is added by a site administrator.' ) ).toBeInTheDocument(); diff --git a/projects/js-packages/publicize-components/src/components/social-post-modal/post-preview.tsx b/projects/js-packages/publicize-components/src/components/social-post-modal/post-preview.tsx index 55cb3717cf7e0..a89ddc5ad3f75 100644 --- a/projects/js-packages/publicize-components/src/components/social-post-modal/post-preview.tsx +++ b/projects/js-packages/publicize-components/src/components/social-post-modal/post-preview.tsx @@ -33,9 +33,9 @@ export type PostPreviewProps = { export function PostPreview( { connection }: PostPreviewProps ) { const user = useMemo( () => ( { - displayName: connection.display_name || connection.external_display, + displayName: connection.display_name, profileImage: connection.profile_picture, - externalName: connection.external_name, + externalName: connection.external_handle, } ), [ connection ] ); diff --git a/projects/js-packages/publicize-components/src/components/social-post-modal/preview-section.tsx b/projects/js-packages/publicize-components/src/components/social-post-modal/preview-section.tsx index 18e656f04082b..cbb0bef242bd5 100644 --- a/projects/js-packages/publicize-components/src/components/social-post-modal/preview-section.tsx +++ b/projects/js-packages/publicize-components/src/components/social-post-modal/preview-section.tsx @@ -33,7 +33,7 @@ export function PreviewSection() { // to avoid errors for old connections like Twitter .filter( ( { service_name } ) => getService( service_name ) ) .map( connection => { - const title = connection.display_name || connection.external_display; + const title = connection.display_name; const name = `${ connection.service_name }-${ connection.connection_id }`; const icon = ( - conn.connection_id - ? conn.connection_id === freshConnection.connection_id - : conn.id === freshConnection.id + const prevConnection = prevConnections.find( + conn => conn.connection_id === freshConnection.connection_id ); const connection = { ...defaults, ...prevConnection, ...freshConnection, - shared: prevConnection?.shared, - is_healthy: freshConnection.test_success, }; connections.push( connection ); } @@ -288,7 +282,7 @@ export function deleteConnectionById( { connectionId, showSuccessNotice = true } const { createErrorNotice, createSuccessNotice } = coreDispatch( globalNoticesStore ); try { - const path = `/jetpack/v4/social/connections/${ connectionId }`; + const path = `/wpcom/v2/publicize/connections/${ connectionId }`; // Abort the refresh connections request. dispatch( abortRefreshConnectionsRequest() ); @@ -347,7 +341,7 @@ export function createConnection( data, optimisticData = {} ) { const tempId = `new-${ ++uniqueId }`; try { - const path = `/jetpack/v4/social/connections/`; + const path = `/wpcom/v2/publicize/connections/`; dispatch( addConnection( { @@ -368,7 +362,6 @@ export function createConnection( data, optimisticData = {} ) { // Updating the connection will also override the connection_id. updateConnection( tempId, { ...connection, - can_disconnect: true, // For editor, we always enable the connection by default. enabled: true, } ) @@ -378,7 +371,7 @@ export function createConnection( data, optimisticData = {} ) { sprintf( /* translators: %s is the name of the social media platform e.g. "Facebook" */ __( '%s account connected successfully.', 'jetpack-publicize-components' ), - connection.label + connection.service_label ), { type: 'snackbar', @@ -467,7 +460,7 @@ export function updateConnectionById( connectionId, data ) { const prevConnection = select.getConnectionById( connectionId ); try { - const path = `/jetpack/v4/social/connections/${ connectionId }`; + const path = `/wpcom/v2/publicize/connections/${ connectionId }`; // Abort the refresh connections request. dispatch( abortRefreshConnectionsRequest() ); diff --git a/projects/js-packages/publicize-components/src/social-store/actions/test/connection-data.js b/projects/js-packages/publicize-components/src/social-store/actions/test/connection-data.js index 86b84bc27c9b3..2e6efa6235e99 100644 --- a/projects/js-packages/publicize-components/src/social-store/actions/test/connection-data.js +++ b/projects/js-packages/publicize-components/src/social-store/actions/test/connection-data.js @@ -119,22 +119,14 @@ describe( 'Social store actions: connectionData', () => { const freshConnections = connections.map( connection => ( { ...connection, - test_success: false, + status: 'broken', } ) ); registry.dispatch( socialStore ).mergeConnections( freshConnections ); const connectionsAfterMerge = registry.select( socialStore ).getConnections(); - expect( connectionsAfterMerge ).toEqual( - freshConnections.map( connection => ( { - ...connection, - // These 3 are added while merging - done: false, - toggleable: true, - is_healthy: false, - } ) ) - ); + expect( connectionsAfterMerge ).toEqual( freshConnections ); } ); } ); @@ -156,10 +148,7 @@ describe( 'Social store actions: connectionData', () => { if ( path.startsWith( refreshConnections ) ) { return connections.map( connection => ( { ...connection, - can_refresh: false, - refresh_url: '', - test_message: 'Some message', - test_success: true, + status: 'broken', } ) ); } @@ -184,14 +173,7 @@ describe( 'Social store actions: connectionData', () => { expect( connectionsAfterRefresh ).toEqual( connections.map( connection => ( { ...connection, - can_refresh: false, - refresh_url: '', - test_message: 'Some message', - test_success: true, - // These 3 are added while merging - done: false, - toggleable: true, - is_healthy: true, + status: 'broken', } ) ) ); diff --git a/projects/js-packages/publicize-components/src/social-store/reducer/connection-data.ts b/projects/js-packages/publicize-components/src/social-store/reducer/connection-data.ts index 29fd84f077177..237d93331c0ea 100644 --- a/projects/js-packages/publicize-components/src/social-store/reducer/connection-data.ts +++ b/projects/js-packages/publicize-components/src/social-store/reducer/connection-data.ts @@ -133,11 +133,7 @@ const connectionData = ( state: ConnectionData = { connections: [] }, action ) = return { ...state, connections: state.connections.map( connection => { - // If the connection has a connection_id, then give it priority. - // Otherwise, use the id. - const isTargetConnection = connection.connection_id - ? connection.connection_id === action.connectionId - : connection.id === action.connectionId; + const isTargetConnection = connection.connection_id === action.connectionId; if ( isTargetConnection ) { return { diff --git a/projects/js-packages/publicize-components/src/social-store/selectors/connection-data.js b/projects/js-packages/publicize-components/src/social-store/selectors/connection-data.ts similarity index 77% rename from projects/js-packages/publicize-components/src/social-store/selectors/connection-data.js rename to projects/js-packages/publicize-components/src/social-store/selectors/connection-data.ts index 25b675d301c4d..ea1a0dc0f6795 100644 --- a/projects/js-packages/publicize-components/src/social-store/selectors/connection-data.js +++ b/projects/js-packages/publicize-components/src/social-store/selectors/connection-data.ts @@ -1,5 +1,8 @@ -import { checkConnectionCode } from '../../utils/connections'; +import { getScriptData } from '@automattic/jetpack-script-data'; +import { store as coreStore } from '@wordpress/core-data'; +import { createRegistrySelector } from '@wordpress/data'; import { REQUEST_TYPE_DEFAULT } from '../actions/constants'; +import { Connection, SocialStoreState } from '../types'; /** * Returns the connections list from the store. @@ -8,7 +11,7 @@ import { REQUEST_TYPE_DEFAULT } from '../actions/constants'; * * @return {Array} The connections list */ -export function getConnections( state ) { +export function getConnections( state: SocialStoreState ) { return state.connectionData?.connections ?? []; } @@ -32,13 +35,7 @@ export function getConnectionById( state, connectionId ) { */ export function getBrokenConnections( state ) { return getConnections( state ).filter( connection => { - return ( - connection.status === 'broken' || - // This is a legacy check for connections that are not healthy. - // TODO remove this check when we are sure that all connections have - // the status property (same schema for connections endpoints), e.g. on Simple/Atomic sites - checkConnectionCode( connection, 'broken' ) - ); + return connection.status === 'broken'; } ); } @@ -48,7 +45,7 @@ export function getBrokenConnections( state ) { * @param {import("../types").SocialStoreState} state - State object. * @param {string} serviceName - The service name. * - * @return {Array} The connections. + * @return {Array} The connections. */ export function getConnectionsByService( state, serviceName ) { return getConnections( state ).filter( ( { service_name } ) => service_name === serviceName ); @@ -72,12 +69,12 @@ export function hasConnections( state ) { export function getFailedConnections( state ) { const connections = getConnections( state ); - return connections.filter( connection => false === connection.test_success ); + return connections.filter( connection => 'broken' === connection.status ); } /** * Returns a list of Publicize connection service names that require reauthentication from users. - * iFor example, when LinkedIn switched its API from v1 to v2. + * For example, when LinkedIn switched its API from v1 to v2. * * @param {import("../types").SocialStoreState} state - State object. * @return {Array} List of service names that need reauthentication. @@ -85,7 +82,7 @@ export function getFailedConnections( state ) { export function getMustReauthConnections( state ) { const connections = getConnections( state ); return connections - .filter( connection => 'must_reauth' === connection.test_success ) + .filter( connection => 'must_reauth' === connection.status ) .map( connection => connection.service_name ); } @@ -132,22 +129,11 @@ export function getConnectionProfileDetails( state, service, { forceDefaults = f ); if ( connection ) { - const { - display_name, - profile_display_name, - profile_picture, - external_display, - external_name, - } = connection; - - displayName = 'twitter' === service ? profile_display_name : display_name || external_display; - username = 'twitter' === service ? display_name : connection.username; - profileImage = profile_picture; + const { display_name, profile_picture, external_handle } = connection; - // Connections schema is a mess - if ( 'bluesky' === service ) { - username = external_name; - } + displayName = display_name; + username = external_handle; + profileImage = profile_picture; } } @@ -199,14 +185,14 @@ export function getAbortControllers( state, requestType = REQUEST_TYPE_DEFAULT ) /** * Whether a mastodon account is already connected. * - * @param {import("../types").SocialStoreState} state - State object. - * @param {string} username - The mastodon username. + * @param {import("../types").SocialStoreState} state - State object. + * @param {string} handle - The mastodon handle. * * @return {boolean} Whether the mastodon account is already connected. */ -export function isMastodonAccountAlreadyConnected( state, username ) { +export function isMastodonAccountAlreadyConnected( state, handle ) { return getConnectionsByService( state, 'mastodon' ).some( connection => { - return connection.external_display === username; + return connection.external_handle === handle; } ); } @@ -220,7 +206,7 @@ export function isMastodonAccountAlreadyConnected( state, username ) { */ export function isBlueskyAccountAlreadyConnected( state, handle ) { return getConnectionsByService( state, 'bluesky' ).some( connection => { - return connection.external_name === handle; + return connection.external_handle === handle; } ); } @@ -244,3 +230,32 @@ export function getKeyringResult( state ) { export function isConnectionsModalOpen( state ) { return state.connectionData?.isConnectionsModalOpen ?? false; } + +/** + * Whether the current user can manage the connection. + */ +export const canUserManageConnection = createRegistrySelector( + select => + ( state: SocialStoreState, connectionOrId: Connection | string ): boolean => { + const connection = + typeof connectionOrId === 'string' + ? getConnectionById( state, connectionOrId ) + : connectionOrId; + + const { current_user } = getScriptData().user; + + // If the current user is the connection owner. + if ( current_user.wpcom?.ID === connection.wpcom_user_id ) { + return true; + } + + const { + // @ts-expect-error getUser exists but `core-data` entities are not typed properly. + // Should work fine after https://github.com/WordPress/gutenberg/pull/67668 is released to npm. + getUser, + } = select( coreStore ); + + // The user has to be at least an editor to manage the connection. + return getUser( current_user.id )?.capabilities?.edit_others_posts ?? false; + } +); diff --git a/projects/js-packages/publicize-components/src/social-store/selectors/index.ts b/projects/js-packages/publicize-components/src/social-store/selectors/index.ts index b7d1c985dda21..4d2ca69c41288 100644 --- a/projects/js-packages/publicize-components/src/social-store/selectors/index.ts +++ b/projects/js-packages/publicize-components/src/social-store/selectors/index.ts @@ -8,8 +8,6 @@ import * as utmSelectors from './utm-settings'; /** * Returns whether the site settings are being saved. - * - * @type {() => boolean} Whether the site settings are being saved. */ export const isSavingSiteSettings = createRegistrySelector( select => () => { return select( coreStore ).isSavingEntityRecord( 'root', 'site', undefined ); diff --git a/projects/js-packages/publicize-components/src/social-store/selectors/test/connection-data.test.js b/projects/js-packages/publicize-components/src/social-store/selectors/test/connection-data.test.js index b73f4d62f1c49..fdb3c21209930 100644 --- a/projects/js-packages/publicize-components/src/social-store/selectors/test/connection-data.test.js +++ b/projects/js-packages/publicize-components/src/social-store/selectors/test/connection-data.test.js @@ -12,34 +12,31 @@ const state = { connectionData: { connections: [ { - id: '123456789', service_name: 'facebook', display_name: 'Some name', profile_picture: 'https://wordpress.com/some-url-of-a-picture', - username: 'username', + external_handle: 'external_handle', enabled: false, connection_id: '987654321', - test_success: true, + status: 'ok', }, { - id: '234567891', service_name: 'tumblr', display_name: 'Some name', profile_picture: 'https://wordpress.com/some-url-of-another-picture', - username: 'username', + external_handle: 'external_handle', enabled: true, connection_id: '198765432', - test_success: false, + status: 'broken', }, { - id: '345678912', service_name: 'mastodon', display_name: 'somename', profile_picture: 'https://wordpress.com/some-url-of-one-more-picture', - username: '@somename@mastodon.social', + external_handle: '@somename@mastodon.social', enabled: false, connection_id: '219876543', - test_success: 'must_reauth', + status: 'must_reauth', }, ], }, @@ -140,7 +137,7 @@ describe( 'Social store selectors: connectionData', () => { expect( getConnectionProfileDetails( state, 'facebook' ) ).toEqual( { displayName: connection.display_name, profileImage: connection.profile_picture, - username: connection.username, + username: connection.external_handle, } ); } ); diff --git a/projects/js-packages/publicize-components/src/social-store/types.ts b/projects/js-packages/publicize-components/src/social-store/types.ts index fb5701967342a..2be401380fd57 100644 --- a/projects/js-packages/publicize-components/src/social-store/types.ts +++ b/projects/js-packages/publicize-components/src/social-store/types.ts @@ -1,25 +1,60 @@ -export type ConnectionStatus = 'ok' | 'broken'; +export type ConnectionStatus = 'ok' | 'broken' | 'must_reauth'; export type Connection = { - id: string; - service_name: string; - label?: string; + connection_id: string; display_name: string; - external_display?: string; - external_id: string; - external_name?: string; - username: string; enabled: boolean; - done: boolean; - toggleable: boolean; - connection_id: string; - is_healthy?: boolean; - error_code?: string; - can_disconnect: boolean; - profile_picture: string; + external_handle: string; + external_id: string; profile_link: string; + profile_picture: string; + service_label: string; + service_name: string; shared: boolean; status: ConnectionStatus; + wpcom_user_id: number; + + /* DEPRECATED FIELDS */ + /** + * @deprecated + */ + done?: boolean; + /** + * @deprecated Use `status` instead. + */ + error_code?: string; + /** + * @deprecated Use `display_name` instead. + */ + external_display?: string; + /** + * @deprecated Use `external_handle` instead. + */ + external_name?: string; + /** + * @deprecated Use `connection_id` instead. + */ + id?: string; + /** + * @deprecated Use `status` instead. + */ + is_healthy?: boolean; + /** + * @deprecated Use `service_label` instead. + */ + label?: string; + /** + * @deprecated Use `status` instead. + */ + test_success?: boolean; + /** + * @deprecated + */ + toggleable?: boolean; + /** + * @deprecated Use `external_handle` instead. + */ + username?: string; }; export type ConnectionData = { diff --git a/projects/js-packages/publicize-components/src/utils/connections.ts b/projects/js-packages/publicize-components/src/utils/connections.ts deleted file mode 100644 index 5ca5e3daf8266..0000000000000 --- a/projects/js-packages/publicize-components/src/utils/connections.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Connection } from '../social-store/types'; - -export const checkConnectionCode = ( connection: Connection, code: string ) => { - return false === connection.is_healthy && code === ( connection.error_code ?? 'broken' ); -}; diff --git a/projects/js-packages/publicize-components/src/utils/test-factory.js b/projects/js-packages/publicize-components/src/utils/test-factory.js index 7c2b310f63807..f65a22f6fe1d8 100644 --- a/projects/js-packages/publicize-components/src/utils/test-factory.js +++ b/projects/js-packages/publicize-components/src/utils/test-factory.js @@ -13,16 +13,16 @@ jest.mock( '../hooks/use-social-media-connections', () => ( { export const setup = ( { connections = [ - { service_name: 'twitter', connection_id: '1', display_name: 'Twitter', can_disconnect: true }, + { service_name: 'twitter', connection_id: '1', display_name: 'Twitter' }, { service_name: 'facebook', connection_id: '2', display_name: 'Facebook', - can_disconnect: true, }, ], getDeletingConnections = [], getUpdatingConnections = [], + canUserManageConnection = true, } = {} ) => { let storeSelect; renderHook( () => useSelect( select => ( storeSelect = select( store ) ) ) ); @@ -36,6 +36,10 @@ export const setup = ( { .mockReset() .mockReturnValue( getUpdatingConnections ); const stubGetKeyringResult = jest.spyOn( storeSelect, 'getKeyringResult' ).mockReset(); + jest + .spyOn( storeSelect, 'canUserManageConnection' ) + .mockReset() + .mockReturnValue( canUserManageConnection ); const { result: dispatch } = renderHook( () => useDispatch( store ) ); const stubDeleteConnectionById = jest diff --git a/projects/js-packages/publicize-components/src/utils/test-utils.js b/projects/js-packages/publicize-components/src/utils/test-utils.js index fa2f326be7bc6..b502817d8f05c 100644 --- a/projects/js-packages/publicize-components/src/utils/test-utils.js +++ b/projects/js-packages/publicize-components/src/utils/test-utils.js @@ -33,34 +33,28 @@ export const testPost = { export const connections = [ { - id: '123456789', service_name: 'facebook', display_name: 'Some name', profile_picture: 'https://wordpress.com/some-url-of-a-picture', - username: 'username', + external_handle: 'username', enabled: false, connection_id: '987654321', - test_success: true, }, { - id: '234567891', service_name: 'tumblr', display_name: 'Some name', profile_picture: 'https://wordpress.com/some-url-of-another-picture', - username: 'username', + external_handle: 'username', enabled: false, connection_id: '198765432', - test_success: false, }, { - id: '345678912', service_name: 'mastodon', display_name: 'somename', profile_picture: 'https://wordpress.com/some-url-of-one-more-picture', - username: '@somename@mastodon.social', + external_handle: '@somename@mastodon.social', enabled: false, connection_id: '219876543', - test_success: 'must_reauth', }, ]; diff --git a/projects/js-packages/script-data/changelog/add-wpcom-data-for-current-user b/projects/js-packages/script-data/changelog/add-wpcom-data-for-current-user new file mode 100644 index 0000000000000..1c4d1fd6232c3 --- /dev/null +++ b/projects/js-packages/script-data/changelog/add-wpcom-data-for-current-user @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Added wpcom data for current user diff --git a/projects/js-packages/script-data/src/types.ts b/projects/js-packages/script-data/src/types.ts index 03d420fdfe7cd..596cdc642eb97 100644 --- a/projects/js-packages/script-data/src/types.ts +++ b/projects/js-packages/script-data/src/types.ts @@ -33,6 +33,10 @@ export interface SiteData extends PublicSiteData, Partial< AdminSiteData > {} export interface CurrentUserData { id: number; display_name: string; + wpcom?: { + ID: number; + login: string; + }; } export interface UserData { diff --git a/projects/packages/publicize/.phan/baseline.php b/projects/packages/publicize/.phan/baseline.php index 73ac3608fa9a5..b819017a9a146 100644 --- a/projects/packages/publicize/.phan/baseline.php +++ b/projects/packages/publicize/.phan/baseline.php @@ -11,13 +11,13 @@ // # Issue statistics: // PhanPluginDuplicateConditionalNullCoalescing : 6 occurrences // PhanTypeMismatchArgument : 6 occurrences + // PhanPluginMixedKeyNoKey : 3 occurrences // PhanTypeMismatchArgumentNullable : 3 occurrences + // PhanUndeclaredClassMethod : 3 occurrences // PhanDeprecatedFunction : 2 occurrences - // PhanPluginMixedKeyNoKey : 2 occurrences // PhanPossiblyUndeclaredVariable : 2 occurrences // PhanTypeMismatchReturnProbablyReal : 2 occurrences // PhanTypeMissingReturn : 2 occurrences - // PhanUndeclaredClassMethod : 2 occurrences // PhanImpossibleCondition : 1 occurrence // PhanNoopNew : 1 occurrence // PhanParamSignatureMismatch : 1 occurrence @@ -29,19 +29,19 @@ // PhanTypeMismatchDefault : 1 occurrence // PhanTypeMismatchDimFetch : 1 occurrence // PhanTypeMismatchReturn : 1 occurrence - // PhanUndeclaredFunction : 1 occurrence + // PhanTypeSuspiciousNonTraversableForeach : 1 occurrence // PhanUndeclaredMethod : 1 occurrence // Currently, file_suppressions and directory_suppressions are the only supported suppressions 'file_suppressions' => [ + 'src/class-connections.php' => ['PhanUndeclaredClassMethod', 'PhanUndeclaredMethod'], 'src/class-keyring-helper.php' => ['PhanTypeMismatchArgumentProbablyReal', 'PhanTypeMismatchDefault'], 'src/class-publicize-base.php' => ['PhanImpossibleCondition', 'PhanPluginDuplicateConditionalNullCoalescing', 'PhanPluginSimplifyExpressionBool', 'PhanSuspiciousMagicConstant', 'PhanTypeMismatchArgument', 'PhanTypeMismatchArgumentNullable', 'PhanTypeMismatchArgumentNullableInternal', 'PhanTypeMismatchDimFetch', 'PhanTypeMismatchReturn'], 'src/class-publicize-setup.php' => ['PhanNoopNew', 'PhanTypeMismatchArgument'], 'src/class-publicize-ui.php' => ['PhanPluginDuplicateExpressionAssignmentOperation', 'PhanTypeMismatchReturnProbablyReal'], 'src/class-publicize.php' => ['PhanParamSignatureMismatch', 'PhanPossiblyUndeclaredVariable', 'PhanTypeMismatchArgument', 'PhanTypeMissingReturn'], 'src/class-rest-controller.php' => ['PhanPluginDuplicateConditionalNullCoalescing', 'PhanTypeMismatchReturnProbablyReal'], - 'src/rest-api/class-base-controller.php' => ['PhanUndeclaredClassMethod', 'PhanUndeclaredFunction'], - 'src/rest-api/class-connections-controller.php' => ['PhanPluginMixedKeyNoKey', 'PhanUndeclaredMethod'], + 'src/rest-api/class-connections-controller.php' => ['PhanPluginMixedKeyNoKey', 'PhanTypeSuspiciousNonTraversableForeach'], 'src/rest-api/class-connections-post-field.php' => ['PhanPluginDuplicateConditionalNullCoalescing'], 'src/social-image-generator/class-post-settings.php' => ['PhanPluginDuplicateConditionalNullCoalescing'], 'src/social-image-generator/class-rest-settings-controller.php' => ['PhanPluginMixedKeyNoKey'], diff --git a/projects/packages/publicize/.phan/config.php b/projects/packages/publicize/.phan/config.php index 075dd16643b9e..d60576709a738 100644 --- a/projects/packages/publicize/.phan/config.php +++ b/projects/packages/publicize/.phan/config.php @@ -13,6 +13,7 @@ return make_phan_config( dirname( __DIR__ ), array( + '+stubs' => array( 'wpcom' ), 'parse_file_list' => array( // Reference files to handle code checking for stuff from Jetpack-the-plugin or other in-monorepo plugins. // Wherever feasible we should really clean up this sort of thing instead of adding stuff here. diff --git a/projects/packages/publicize/changelog/fix-social-connections-list-feature-check b/projects/packages/publicize/changelog/fix-social-connections-list-feature-check new file mode 100644 index 0000000000000..db871891de374 --- /dev/null +++ b/projects/packages/publicize/changelog/fix-social-connections-list-feature-check @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Social | Fix feature check for social connections list initial state diff --git a/projects/packages/publicize/changelog/social-unified-connections-management b/projects/packages/publicize/changelog/social-unified-connections-management new file mode 100644 index 0000000000000..03f5d101125bf --- /dev/null +++ b/projects/packages/publicize/changelog/social-unified-connections-management @@ -0,0 +1,4 @@ +Significance: minor +Type: changed + +Social | Unify connections management API schema diff --git a/projects/packages/publicize/src/class-connections.php b/projects/packages/publicize/src/class-connections.php new file mode 100644 index 0000000000000..6d186ed0e09c0 --- /dev/null +++ b/projects/packages/publicize/src/class-connections.php @@ -0,0 +1,405 @@ + 'blog' ) ); + } else { + + $ignore_cache = $args['ignore_cache'] ?? false; + + $connections = get_transient( self::CONNECTIONS_TRANSIENT ); + + if ( $ignore_cache || false === $connections ) { + $connections = self::fetch_and_cache_connections(); + } + } + + // Let us add the deprecated fields for now. + // TODO: Remove this after https://github.com/Automattic/jetpack/pull/40539 is merged. + $connections = self::retain_deprecated_fields( $connections ); + + return $connections; + } + + /** + * Get a connection by connection_id. + * + * @param string $connection_id Connection ID. + * + * @return array|null + */ + public static function get_by_id( $connection_id ) { + + $connections = self::get_all(); + + foreach ( $connections as $connection ) { + if ( $connection['connection_id'] === $connection_id ) { + return $connection; + } + } + + return null; + } + + /** + * Get all connections for the current user. + * + * @param array $args Arguments. Same as self::get_all(). + * + * @see Automattic\Jetpack\Publicize\Connections::get_all() + * + * @return array + */ + public static function get_all_for_user( $args = array() ) { + $connections = self::get_all( $args ); + + $connections_for_user = array(); + + foreach ( $connections as $connection ) { + + if ( $connection['shared'] || self::user_owns_connection( $connection ) ) { + $connections_for_user[] = $connection; + } + } + + return $connections_for_user; + } + + /** + * Whether the current user owns a connection. + * + * @param array $connection The connection. + * @param int $user_id The user ID. Defaults to the current user. + * + * @return bool + */ + public static function user_owns_connection( $connection, $user_id = null ) { + if ( Publicize_Utils::is_wpcom() ) { + $wpcom_user_id = get_current_user_id(); + } else { + + $wpcom_user_data = ( new Connection\Manager() )->get_connected_user_data( $user_id ); + + $wpcom_user_id = ! empty( $wpcom_user_data['ID'] ) ? $wpcom_user_data['ID'] : null; + } + + return $wpcom_user_id && $connection['wpcom_user_id'] === $wpcom_user_id; + } + + /** + * Retain deprecated fields. + * + * @param array $connections Connections. + * @return array + */ + private static function retain_deprecated_fields( $connections ) { + return array_map( + function ( $connection ) { + + $owns_connection = self::user_owns_connection( $connection ); + + $connection = array_merge( + $connection, + array( + 'external_display' => $connection['display_name'], + 'can_disconnect' => current_user_can( 'edit_others_posts' ) || $owns_connection, + 'label' => $connection['service_label'], + ) + ); + + if ( 'bluesky' === $connection['service_name'] ) { + $connection['external_name'] = $connection['external_handle']; + } + + return $connection; + }, + $connections + ); + } + + /** + * Fetch connections from the REST API and cache them. + * + * @return array + */ + public static function fetch_and_cache_connections() { + $connections = self::fetch_site_connections(); + + if ( is_array( $connections ) ) { + if ( ! set_transient( self::CONNECTIONS_TRANSIENT, $connections, HOUR_IN_SECONDS * 4 ) ) { + // If the transient has beeen set in another request, the call to set_transient can fail. + // If so, we can delete the transient and try again. + self::clear_cache(); + + set_transient( self::CONNECTIONS_TRANSIENT, $connections, HOUR_IN_SECONDS * 4 ); + } + } + + return $connections; + } + + /** + * Fetch connections for the site from WPCOM REST API. + * + * @return array + */ + public static function fetch_site_connections() { + $proxy = new Proxy_Requests( 'publicize/connections' ); + + $request = new WP_REST_Request( 'GET', '/wpcom/v2/publicize/connections' ); + + $connections = $proxy->proxy_request_to_wpcom_as_blog( $request ); + + if ( is_wp_error( $connections ) ) { + // @todo log error. + return array(); + } + + return $connections; + } + + /** + * Get all connections. Meant to be called directly only on WPCOM. + * + * @param array $args Arguments + * - 'test_connections': bool Whether to run connection tests. + * - 'context': enum('blog', 'user') Whether to include connections for the current blog or user. + * + * @return array + */ + public static function wpcom_get_connections( $args = array() ) { + // Ensure that we are on WPCOM. + Publicize_Utils::assert_is_wpcom( __METHOD__ ); + + /** + * Publicize instance. + */ + global $publicize; + + $items = array(); + + $run_tests = $args['test_connections'] ?? false; + + $test_results = $run_tests ? self::get_test_status() : array(); + + $service_connections = $publicize->get_all_connections_for_blog_id( get_current_blog_id() ); + + $context = $args['context'] ?? 'user'; + + foreach ( $service_connections as $service_name => $connections ) { + foreach ( $connections as $connection ) { + $connection_id = $publicize->get_connection_id( $connection ); + + $item = self::wpcom_prepare_connection_data( $connection, $service_name ); + + $item['status'] = $test_results[ $connection_id ] ?? null; + + // For blog context, return all connections. + // Otherwise, return only connections owned by the user and the shared ones. + if ( 'blog' === $context || $item['shared'] || self::user_owns_connection( $item ) ) { + $items[] = $item; + } + } + } + + return $items; + } + + /** + * Filters out data based on ?_fields= request parameter + * + * @param mixed $connection Connection to prepare. + * @param string $service_name Service name. + * + * @return array + */ + public static function wpcom_prepare_connection_data( $connection, $service_name ) { + // Ensure that we are on WPCOM. + Publicize_Utils::assert_is_wpcom( __METHOD__ ); + + /** + * Publicize instance. + */ + global $publicize; + + $connection_id = $publicize->get_connection_id( $connection ); + + $connection_meta = $publicize->get_connection_meta( $connection ); + $connection_data = $connection_meta['connection_data']; + + return array( + 'connection_id' => (string) $connection_id, + 'display_name' => (string) $publicize->get_display_name( $service_name, $connection ), + 'external_handle' => (string) $publicize->get_external_handle( $service_name, $connection ), + 'external_id' => $connection_meta['external_id'] ?? '', + 'profile_link' => (string) $publicize->get_profile_link( $service_name, $connection ), + 'profile_picture' => (string) $publicize->get_profile_picture( $connection ), + 'service_label' => (string) Publicize::get_service_label( $service_name ), + 'service_name' => $service_name, + 'shared' => ! $connection_data['user_id'], + 'wpcom_user_id' => (int) $connection_data['user_id'], + + // Deprecated fields. + 'id' => (string) $publicize->get_connection_unique_id( $connection ), + 'username' => $publicize->get_username( $service_name, $connection ), + 'profile_display_name' => ! empty( $connection_meta['profile_display_name'] ) ? $connection_meta['profile_display_name'] : '', + // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual -- We expect an integer, but do loose comparison below in case some other type is stored. + 'global' => 0 == $connection_data['user_id'], + + ); + } + + /** + * Create a connection. Meant to be called directly only on WPCOM. + * + * @param mixed $input Input data. + * + * @return string|WP_Error Connection ID or WP_Error. + */ + public static function wpcom_create_connection( $input ) { + // Ensure that we are on WPCOM. + Publicize_Utils::assert_is_wpcom( __METHOD__ ); + + require_lib( 'social-connections-rest-helper' ); + + $connections_helper = \Social_Connections_Rest_Helper::init(); + + $result = $connections_helper->create_publicize_connection( $input ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + if ( ! isset( $result['ID'] ) ) { + return new WP_Error( + 'wpcom_connection_creation_failed', + __( 'Something went wrong while creating a connection.', 'jetpack-publicize-pkg' ) + ); + } + + return (string) $result['ID']; + } + + /** + * Update a connection. Meant to be called directly only on WPCOM. + * + * @param string $connection_id Connection ID. + * @param mixed $input Input data. + * + * @return string|WP_Error Connection ID or WP_Error. + */ + public static function wpcom_update_connection( $connection_id, $input ) { + // Ensure that we are on WPCOM. + Publicize_Utils::assert_is_wpcom( __METHOD__ ); + + require_lib( 'social-connections-rest-helper' ); + $connections_helper = \Social_Connections_Rest_Helper::init(); + + $result = $connections_helper->update_connection( $connection_id, $input ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + if ( ! $result ) { + return new WP_Error( + 'wpcom_connection_updation_failed', + __( 'Something went wrong while updating the connection.', 'jetpack-publicize-pkg' ) + ); + } + + return (string) $connection_id; + } + + /** + * Delete a connection. Meant to be called directly only on WPCOM. + * + * @param string $connection_id Connection ID. + * + * @return bool|WP_Error + */ + public static function wpcom_delete_connection( $connection_id ) { + // Ensure that we are on WPCOM. + Publicize_Utils::assert_is_wpcom( __METHOD__ ); + + require_lib( 'social-connections-rest-helper' ); + $connections_helper = \Social_Connections_Rest_Helper::init(); + + $result = $connections_helper->delete_publicize_connection( $connection_id ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + if ( ! $result ) { + return new WP_Error( + 'wpcom_connection_deletion_failed', + __( 'Something went wrong while deleting the connection.', 'jetpack-publicize-pkg' ) + ); + } + + return true; + } + + /** + * Get the connections test status. + * + * @return array + */ + public static function get_test_status() { + /** + * Publicize instance. + * + * @var \Automattic\Jetpack\Publicize\Publicize $publicize + */ + global $publicize; + + $test_results = $publicize->get_publicize_conns_test_results(); + + $test_results_map = array(); + + foreach ( $test_results as $test_result ) { + $result = $test_result['connectionTestPassed']; + if ( 'must_reauth' !== $result ) { + $result = $test_result['connectionTestPassed'] ? 'ok' : 'broken'; + } + $test_results_map[ $test_result['connectionID'] ] = $result; + } + + return $test_results_map; + } + + /** + * Clear the connections cache. + */ + public static function clear_cache() { + delete_transient( self::CONNECTIONS_TRANSIENT ); + } +} diff --git a/projects/packages/publicize/src/class-publicize-base.php b/projects/packages/publicize/src/class-publicize-base.php index 076abd34acdc6..d707968be7b2f 100644 --- a/projects/packages/publicize/src/class-publicize-base.php +++ b/projects/packages/publicize/src/class-publicize-base.php @@ -556,8 +556,8 @@ public function get_profile_link( $service_name, $connection ) { public function get_display_name( $service_name, $connection ) { $cmeta = $this->get_connection_meta( $connection ); - if ( 'mastodon' === $service_name && isset( $cmeta['external_name'] ) ) { - return $cmeta['external_name']; + if ( 'mastodon' === $service_name && isset( $cmeta['external_display'] ) ) { + return $cmeta['external_display']; } if ( isset( $cmeta['connection_data']['meta']['display_name'] ) ) { @@ -1015,7 +1015,6 @@ public function get_filtered_connection_data( $selected_post_id = null ) { 'service_name' => $service_name, 'shared' => ! $connection_data['user_id'], 'status' => null, - 'user_id' => (int) $connection_data['user_id'], // Deprecated fields. 'id' => $connection_id, @@ -1024,6 +1023,7 @@ public function get_filtered_connection_data( $selected_post_id = null ) { 'done' => $done, 'toggleable' => $toggleable, 'global' => 0 == $connection_data['user_id'], // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual,WordPress.PHP.StrictComparisons.LooseComparison -- Other types can be used at times. + 'user_id' => (int) $connection_data['user_id'], ); } } diff --git a/projects/packages/publicize/src/class-publicize-script-data.php b/projects/packages/publicize/src/class-publicize-script-data.php index 3035e3e6b1d71..c40d5f51a31c9 100644 --- a/projects/packages/publicize/src/class-publicize-script-data.php +++ b/projects/packages/publicize/src/class-publicize-script-data.php @@ -21,8 +21,6 @@ */ class Publicize_Script_Data { - const SERVICES_TRANSIENT = 'jetpack_social_services_list'; - /** * Get the publicize instance - properly typed * @@ -75,9 +73,32 @@ public static function set_admin_script_data( $data ) { $data['site']['host'] = ( new Host() )->get_known_host_guess(); } + self::set_wpcom_user_data( $data['user']['current_user'] ); + return $data; } + /** + * Set wpcom user data. + * + * @param array $user_data The user data. + */ + private static function set_wpcom_user_data( &$user_data ) { + if ( ( new Host() )->is_wpcom_simple() ) { + $wpcom_user_data = array( + 'ID' => get_current_user_id(), + 'login' => wp_get_current_user()->user_login, + ); + } else { + $wpcom_user_data = ( new Manager() )->get_connected_user_data(); + } + + $user_data['wpcom'] = array_merge( + $user_data['wpcom'] ?? array(), + $wpcom_user_data ? $wpcom_user_data : array() + ); + } + /** * Get the script data for admin UI. * @@ -163,8 +184,7 @@ public static function get_store_initial_state() { return array( 'connectionData' => array( - // We do not have this method on WPCOM Publicize class yet. - 'connections' => ! $is_wpcom ? self::publicize()->get_all_connections_for_user() : array(), + 'connections' => self::has_feature_flag( 'connections-management' ) ? Connections::get_all_for_user() : array(), ), 'shareStatus' => $share_status, ); @@ -247,17 +267,24 @@ public static function get_api_paths() { $is_wpcom = ( new Host() )->is_wpcom_platform(); + $commom_paths = array( + 'refreshConnections' => '/wpcom/v2/publicize/connections?test_connections=1', + ); + + $specific_paths = array(); + if ( $is_wpcom ) { - return array( - 'refreshConnections' => '/wpcom/v2/publicize/connection-test-results', - 'resharePost' => '/wpcom/v2/posts/{postId}/publicize', + + $specific_paths = array( + 'resharePost' => '/wpcom/v2/posts/{postId}/publicize', + ); + } else { + $specific_paths = array( + 'resharePost' => '/jetpack/v4/publicize/{postId}', ); } - return array( - 'refreshConnections' => '/jetpack/v4/publicize/connections?test_connections=1', - 'resharePost' => '/jetpack/v4/publicize/{postId}', - ); + return array_merge( $commom_paths, $specific_paths ); } /** diff --git a/projects/packages/publicize/src/class-publicize-utils.php b/projects/packages/publicize/src/class-publicize-utils.php index 77f5272f0ba93..ef31f3c8ee24d 100644 --- a/projects/packages/publicize/src/class-publicize-utils.php +++ b/projects/packages/publicize/src/class-publicize-utils.php @@ -87,4 +87,26 @@ public static function is_connected() { public static function is_publicize_active() { return ( new Modules() )->is_active( 'publicize' ); } + + /** + * Check if we are on WPCOM. + * + * @return bool + */ + public static function is_wpcom() { + return ( new Host() )->is_wpcom_simple(); + } + + /** + * Assert that the method is only called on WPCOM. + * + * @param string $method The method name. + * + * @throws \Exception If the method is not called on WPCOM. + */ + public static function assert_is_wpcom( $method ) { + if ( ! self::is_wpcom() ) { + throw new \Exception( esc_html( "Method $method can only be called on WordPress.com." ) ); + } + } } diff --git a/projects/packages/publicize/src/class-publicize.php b/projects/packages/publicize/src/class-publicize.php index beb043db17f16..0a444a86bf5a8 100644 --- a/projects/packages/publicize/src/class-publicize.php +++ b/projects/packages/publicize/src/class-publicize.php @@ -145,6 +145,10 @@ public function disconnect( $service_name, $connection_id, $_blog_id = false, $_ * @return true */ public function receive_updated_publicize_connections( $publicize_connections ) { + + // Populate the cache with the new data. + Connections::get_all( array( 'ignore_cache' => true ) ); + $expiry = 3600 * 4; if ( ! set_transient( self::JETPACK_SOCIAL_CONNECTIONS_TRANSIENT, $publicize_connections, $expiry ) ) { // If the transient has beeen set in another request, the call to set_transient can fail. If so, diff --git a/projects/packages/publicize/src/class-services.php b/projects/packages/publicize/src/class-services.php index 26e49b096b40b..b43c6af0aae0a 100644 --- a/projects/packages/publicize/src/class-services.php +++ b/projects/packages/publicize/src/class-services.php @@ -26,11 +26,9 @@ class Services { public static function get_all( $force_refresh = false ) { if ( defined( 'IS_WPCOM' ) && constant( 'IS_WPCOM' ) ) { if ( function_exists( 'require_lib' ) ) { - // @phan-suppress-next-line PhanUndeclaredFunction - phan is dumb not to see the function_exists check. require_lib( 'external-connections' ); } - // @phan-suppress-next-line PhanUndeclaredClassMethod - We are here because we are on WPCOM. $external_connections = \WPCOM_External_Connections::init(); $services = array_values( $external_connections->get_external_services_list( 'publicize', get_current_blog_id() ) ); diff --git a/projects/packages/publicize/src/rest-api/class-base-controller.php b/projects/packages/publicize/src/rest-api/class-base-controller.php index 86ca5c8e40a52..fd0f7a870a759 100644 --- a/projects/packages/publicize/src/rest-api/class-base-controller.php +++ b/projects/packages/publicize/src/rest-api/class-base-controller.php @@ -7,7 +7,8 @@ namespace Automattic\Jetpack\Publicize\REST_API; -use Automattic\Jetpack\Status\Host; +use Automattic\Jetpack\Publicize\Connections; +use Automattic\Jetpack\Publicize\Publicize_Utils; use WP_Error; use WP_REST_Controller; use WP_REST_Request; @@ -32,22 +33,13 @@ public function __construct() { $this->wpcom_is_wpcom_only_endpoint = true; } - /** - * Check if we are on WPCOM. - * - * @return bool - */ - public static function is_wpcom() { - return ( new Host() )->is_wpcom_simple(); - } - /** * Check if the request is authorized for the blog. * * @return bool */ protected static function is_authorized_blog_request() { - if ( self::is_wpcom() && is_jetpack_site( get_current_blog_id() ) ) { + if ( Publicize_Utils::is_wpcom() && is_jetpack_site( get_current_blog_id() ) ) { $jp_auth_endpoint = new \WPCOM_REST_API_V2_Endpoint_Jetpack_Auth(); @@ -82,10 +74,9 @@ public function prepare_item_for_response( $item, $request ) { /** * Verify that user can access Publicize data * - * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error */ - public function get_items_permissions_check( $request ) {// phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + protected function publicize_permissions_check() { global $publicize; @@ -111,4 +102,23 @@ public function get_items_permissions_check( $request ) {// phpcs:ignore Variabl array( 'status' => rest_authorization_required_code() ) ); } + + /** + * Check whether the request is allowed to manage (update/delete) a connection. + * + * @param WP_REST_Request $request Full details about the request. + * @return bool True if the request can manage connection, false otherwise. + */ + protected function manage_connection_permission_check( $request ) { + // Editors and above can manage any connection. + if ( current_user_can( 'edit_others_posts' ) ) { + return true; + } + + $connection_id = $request->get_param( 'connection_id' ); + + $connection = Connections::get_by_id( $connection_id ); + + return Connections::user_owns_connection( $connection ); + } } diff --git a/projects/packages/publicize/src/rest-api/class-connections-controller.php b/projects/packages/publicize/src/rest-api/class-connections-controller.php index 23725b12a33a1..0e33f29499292 100644 --- a/projects/packages/publicize/src/rest-api/class-connections-controller.php +++ b/projects/packages/publicize/src/rest-api/class-connections-controller.php @@ -7,9 +7,10 @@ namespace Automattic\Jetpack\Publicize\REST_API; -use Automattic\Jetpack\Connection\Client; -use Automattic\Jetpack\Connection\Manager; -use Automattic\Jetpack\Publicize\Publicize; +use Automattic\Jetpack\Connection\Traits\WPCOM_REST_API_Proxy_Request; +use Automattic\Jetpack\Publicize\Connections; +use Automattic\Jetpack\Publicize\Publicize_Utils; +use WP_Error; use WP_REST_Request; use WP_REST_Response; use WP_REST_Server; @@ -19,12 +20,18 @@ */ class Connections_Controller extends Base_Controller { + use WPCOM_REST_API_Proxy_Request; + /** * Constructor. */ public function __construct() { parent::__construct(); - $this->namespace = 'wpcom/v2'; + + $this->base_api_path = 'wpcom'; + $this->version = 'v2'; + + $this->namespace = "{$this->base_api_path}/{$this->version}"; $this->rest_base = 'publicize/connections'; $this->allow_requests_as_blog = true; @@ -51,6 +58,58 @@ public function register_routes() { ), ), ), + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'create_item' ), + 'permission_callback' => array( $this, 'create_item_permissions_check' ), + 'args' => array( + 'keyring_connection_ID' => array( + 'description' => __( 'Keyring connection ID.', 'jetpack-publicize-pkg' ), + 'type' => 'integer', + 'required' => true, + ), + 'external_user_ID' => array( + 'description' => __( 'External User Id - in case of services like Facebook.', 'jetpack-publicize-pkg' ), + 'type' => 'string', + ), + 'shared' => array( + 'description' => __( 'Whether the connection is shared with other users.', 'jetpack-publicize-pkg' ), + 'type' => 'boolean', + ), + ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[0-9]+)', + array( + 'args' => array( + 'connection_id' => array( + 'description' => __( 'Unique identifier for the connection.', 'jetpack-publicize-pkg' ), + 'type' => 'string', + 'required' => true, + ), + ), + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'update_item' ), + 'permission_callback' => array( $this, 'update_item_permissions_check' ), + 'args' => array( + 'shared' => array( + 'description' => __( 'Whether the connection is shared with other users.', 'jetpack-publicize-pkg' ), + 'type' => 'boolean', + ), + ), + ), + array( + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => array( $this, 'delete_item' ), + 'permission_callback' => array( $this, 'delete_item_permissions_check' ), + + ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); @@ -164,173 +223,267 @@ public static function get_the_item_schema() { 'enum' => array( 'ok', 'broken', + 'must_reauth', null, ), ), - 'user_id' => array( + 'wpcom_user_id' => array( 'type' => 'integer', - 'description' => __( 'ID of the user the connection belongs to. It is the user ID on wordpress.com', 'jetpack-publicize-pkg' ), + 'description' => __( 'wordpress.com ID of the user the connection belongs to.', 'jetpack-publicize-pkg' ), ), ); } /** - * Get all connections. Meant to be called directly only on WPCOM. + * Verify that the request has access to connectoins list. + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error + */ + public function get_items_permissions_check( $request ) {// phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + return $this->publicize_permissions_check(); + } + + /** + * Get list of connected Publicize connections. * - * @param array $args Arguments - * - 'test_connections': bool Whether to run connection tests. - * - 'scope': enum('site', 'user') Which connections to include. + * @param WP_REST_Request $request Full details about the request. * - * @return array + * @return WP_REST_Response suitable for 1-page collection */ - protected static function get_all_connections( $args = array() ) { - /** - * Publicize instance. - */ - global $publicize; + public function get_items( $request ) { + if ( Publicize_Utils::is_wpcom() ) { + $args = array( + 'context' => self::is_authorized_blog_request() ? 'blog' : 'user', + 'test_connections' => $request->get_param( 'test_connections' ), + ); - $items = array(); + $connections = Connections::wpcom_get_connections( $args ); + } else { + $connections = $this->proxy_request_to_wpcom_as_user( $request ); + } - $run_tests = $args['test_connections'] ?? false; + if ( is_wp_error( $connections ) ) { + return $connections; + } - $test_results = $run_tests ? self::get_connections_test_status() : array(); + $items = array(); - // If a (Jetpack) blog request, return all the connections for that site. - if ( self::is_authorized_blog_request() ) { - $service_connections = $publicize->get_all_connections_for_blog_id( get_current_blog_id() ); - } else { - $service_connections = (array) $publicize->get_services( 'connected' ); - } + foreach ( $connections as $item ) { + $data = $this->prepare_item_for_response( $item, $request ); - foreach ( $service_connections as $service_name => $connections ) { - foreach ( $connections as $connection ) { - - $connection_id = $publicize->get_connection_id( $connection ); - - $connection_meta = $publicize->get_connection_meta( $connection ); - $connection_data = $connection_meta['connection_data']; - - $items[] = array( - 'connection_id' => (string) $connection_id, - 'display_name' => (string) $publicize->get_display_name( $service_name, $connection ), - 'external_handle' => (string) $publicize->get_external_handle( $service_name, $connection ), - 'external_id' => $connection_meta['external_id'] ?? '', - 'profile_link' => (string) $publicize->get_profile_link( $service_name, $connection ), - 'profile_picture' => (string) $publicize->get_profile_picture( $connection ), - 'service_label' => (string) Publicize::get_service_label( $service_name ), - 'service_name' => $service_name, - 'shared' => ! $connection_data['user_id'], - 'status' => $test_results[ $connection_id ] ?? null, - 'user_id' => (int) $connection_data['user_id'], - - // Deprecated fields. - 'id' => (string) $publicize->get_connection_unique_id( $connection ), - 'username' => $publicize->get_username( $service_name, $connection ), - 'profile_display_name' => ! empty( $connection_meta['profile_display_name'] ) ? $connection_meta['profile_display_name'] : '', - // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual -- We expect an integer, but do loose comparison below in case some other type is stored. - 'global' => 0 == $connection_data['user_id'], - - ); - } + $items[] = $this->prepare_response_for_collection( $data ); } - return $items; + $response = rest_ensure_response( $items ); + $response->header( 'X-WP-Total', (string) count( $items ) ); + $response->header( 'X-WP-TotalPages', '1' ); + + return $response; } /** - * Get a list of publicize connections. - * - * @param array $args Arguments. + * Checks if a given request has access to create a connection. * - * @see Automattic\Jetpack\Publicize\REST_API\Connections_Controller::get_all_connections() + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has access to create items, WP_Error object otherwise. + */ + public function create_item_permissions_check( $request ) {// phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + $permissions = parent::publicize_permissions_check(); + + if ( is_wp_error( $permissions ) ) { + return $permissions; + } + + return current_user_can( 'publish_posts' ); + } + + /** + * Creates a new connection. * - * @return array + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ - public static function get_connections( $args = array() ) { - if ( self::is_wpcom() ) { - return self::get_all_connections( $args ); + public function create_item( $request ) { + if ( Publicize_Utils::is_wpcom() ) { + + $input = array( + 'keyring_connection_ID' => $request->get_param( 'keyring_connection_ID' ), + 'shared' => $request->get_param( 'shared' ), + ); + + $external_user_id = $request->get_param( 'external_user_ID' ); + if ( ! empty( $external_user_id ) ) { + $input['external_user_ID'] = $external_user_id; + } + + $result = Connections::wpcom_create_connection( $input ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + $connection = Connections::get_by_id( $result ); + + $response = $this->prepare_item_for_response( $connection, $request ); + $response = rest_ensure_response( $response ); + + $response->set_status( 201 ); + + return $response; + } - $site_id = Manager::get_site_id( true ); - if ( ! $site_id ) { - return array(); + $response = $this->proxy_request_to_wpcom_as_user( $request, '', array( 'timeout' => 120 ) ); + + if ( is_wp_error( $response ) ) { + return new WP_Error( + 'jp_connection_update_failed', + __( 'Something went wrong while creating a connection.', 'jetpack-publicize-pkg' ), + $response->get_error_message() + ); } - $path = add_query_arg( - array( - 'test_connections' => $args['test_connections'] ?? false, - ), - sprintf( '/sites/%d/publicize/connections', $site_id ) - ); + $response = rest_ensure_response( $response ); - $blog_or_user = ( $args['scope'] ?? '' ) === 'site' ? 'blog' : 'user'; + $response->set_status( 201 ); - $callback = array( Client::class, "wpcom_json_api_request_as_{$blog_or_user}" ); + return $response; + } - $response = call_user_func( $callback, $path, 'v2', array( 'method' => 'GET' ), null, 'wpcom' ); + /** + * Checks if a given request has access to update a connection. + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has access to create items, WP_Error object otherwise. + */ + public function update_item_permissions_check( $request ) { + $permissions = parent::publicize_permissions_check(); - if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { - // TODO log error. - return array(); + if ( is_wp_error( $permissions ) ) { + return $permissions; } - $body = wp_remote_retrieve_body( $response ); + // If the user cannot manage the connection, they can't update it either. + if ( ! $this->manage_connection_permission_check( $request ) ) { + return new WP_Error( + 'rest_cannot_edit', + __( 'Sorry, you are not allowed to update this connection.', 'jetpack-publicize-pkg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } - $items = json_decode( $body, true ); + // If the connection is being marked/unmarked as shared. + if ( $request->has_param( 'shared' ) ) { + // Only editors and above can mark a connection as shared. + return current_user_can( 'edit_others_posts' ); + } - return $items ? $items : array(); + return current_user_can( 'publish_posts' ); } /** - * Get list of connected Publicize connections. + * Update a connection. * * @param WP_REST_Request $request Full details about the request. - * - * @return WP_REST_Response suitable for 1-page collection + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ - public function get_items( $request ) { - $items = array(); + public function update_item( $request ) { + $connection_id = $request->get_param( 'connection_id' ); - // On Jetpack, we don't want to pass the 'scope' param to get_connections(). - $args = array( - 'test_connections' => $request->get_param( 'test_connections' ), - ); + if ( Publicize_Utils::is_wpcom() ) { - foreach ( self::get_connections( $args ) as $item ) { - $data = $this->prepare_item_for_response( $item, $request ); + $input = array( + 'shared' => $request->get_param( 'shared' ), + ); - $items[] = $this->prepare_response_for_collection( $data ); + $result = Connections::wpcom_update_connection( $connection_id, $input ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + $connection = Connections::get_by_id( $connection_id ); + + $response = $this->prepare_item_for_response( $connection, $request ); + $response = rest_ensure_response( $response ); + + $response->set_status( 201 ); + + return $response; } - $response = rest_ensure_response( $items ); - $response->header( 'X-WP-Total', (string) count( $items ) ); - $response->header( 'X-WP-TotalPages', '1' ); + $response = $this->proxy_request_to_wpcom_as_user( $request, $connection_id, array( 'timeout' => 120 ) ); + + if ( is_wp_error( $response ) ) { + return new WP_Error( + 'jp_connection_updation_failed', + __( 'Something went wrong while updating the connection.', 'jetpack-publicize-pkg' ), + $response->get_error_message() + ); + } + + $response = rest_ensure_response( $response ); + + $response->set_status( 201 ); return $response; } /** - * Get the connections test status. + * Checks if a given request has access to delete a connection. * - * @return array + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has access to create items, WP_Error object otherwise. + */ + public function delete_item_permissions_check( $request ) { + $permissions = parent::publicize_permissions_check(); + + if ( is_wp_error( $permissions ) ) { + return $permissions; + } + + return $this->manage_connection_permission_check( $request ); + } + + /** + * Delete a connection. + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ - protected static function get_connections_test_status() { - /** - * Publicize instance. - * - * @var \Automattic\Jetpack\Publicize\Publicize $publicize - */ - global $publicize; + public function delete_item( $request ) { + $connection_id = $request->get_param( 'connection_id' ); + + if ( Publicize_Utils::is_wpcom() ) { + + $result = Connections::wpcom_delete_connection( $connection_id ); - $test_results = $publicize->get_publicize_conns_test_results(); + if ( is_wp_error( $result ) ) { + return $result; + } + + $response = rest_ensure_response( $result ); + + $response->set_status( 201 ); + + return $response; + } - $test_results_map = array(); + $response = $this->proxy_request_to_wpcom_as_user( $request, $connection_id, array( 'timeout' => 120 ) ); - foreach ( $test_results as $test_result ) { - // Compare to `true` because the API returns a 'must_reauth' for LinkedIn. - $test_results_map[ $test_result['connectionID'] ] = true === $test_result['connectionTestPassed'] ? 'ok' : 'broken'; + if ( is_wp_error( $response ) ) { + return new WP_Error( + 'jp_connection_deletion_failed', + __( 'Something went wrong while deleting the connection.', 'jetpack-publicize-pkg' ), + $response->get_error_message() + ); } - return $test_results_map; + $response = rest_ensure_response( $response ); + + $response->set_status( 201 ); + + return $response; } } diff --git a/projects/packages/publicize/src/rest-api/class-connections-post-field.php b/projects/packages/publicize/src/rest-api/class-connections-post-field.php index a12047e30d912..647655b211d37 100644 --- a/projects/packages/publicize/src/rest-api/class-connections-post-field.php +++ b/projects/packages/publicize/src/rest-api/class-connections-post-field.php @@ -7,6 +7,7 @@ namespace Automattic\Jetpack\Publicize\REST_API; +use Automattic\Jetpack\Publicize\Connections; use WP_Error; use WP_Post; use WP_REST_Request; @@ -179,6 +180,16 @@ public function get( $post_array, $field_name, $request, $object_type ) { // php $properties = array_keys( $schema['properties'] ); $connections = $publicize->get_filtered_connection_data( $post_id ); + $connections_id_map = array_reduce( + Connections::get_all(), + function ( $map, $connection ) { + $map[ $connection['connection_id'] ] = $connection; + + return $map; + }, + array() + ); + $output_connections = array(); foreach ( $connections as $connection ) { $output_connection = array(); @@ -190,6 +201,7 @@ public function get( $post_array, $field_name, $request, $object_type ) { // php $output_connection['id'] = (string) $connection['unique_id']; $output_connection['can_disconnect'] = current_user_can( 'edit_others_posts' ) || get_current_user_id() === (int) $connection['user_id']; + $output_connection['wpcom_user_id'] = $connections_id_map[ $connection['connection_id'] ]['wpcom_user_id'] ?? 0; $output_connections[] = $output_connection; } diff --git a/projects/packages/publicize/src/rest-api/class-proxy-requests.php b/projects/packages/publicize/src/rest-api/class-proxy-requests.php new file mode 100644 index 0000000000000..58839e5010796 --- /dev/null +++ b/projects/packages/publicize/src/rest-api/class-proxy-requests.php @@ -0,0 +1,31 @@ +rest_base = $rest_base; + $this->base_api_path = $base_api_path; + $this->version = $version; + } +} diff --git a/projects/plugins/jetpack/changelog/fix-social-connections-list-feature-check b/projects/plugins/jetpack/changelog/fix-social-connections-list-feature-check new file mode 100644 index 0000000000000..76c3a4215d02f --- /dev/null +++ b/projects/plugins/jetpack/changelog/fix-social-connections-list-feature-check @@ -0,0 +1,4 @@ +Significance: patch +Type: bugfix + +Social | Fix publicize error in the editor due to malformed connections data diff --git a/projects/plugins/social/changelog/fix-social-connections-list-feature-check b/projects/plugins/social/changelog/fix-social-connections-list-feature-check new file mode 100644 index 0000000000000..d4868fdf6e0a2 --- /dev/null +++ b/projects/plugins/social/changelog/fix-social-connections-list-feature-check @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Fix publicize error in the editor due to malformed connections data