How to build a WordPress Client Portal plugin

When building a company website, chances are you’ll be looking for a client portal plugin where an administrator can add files and information related to a particular client. And most of the times it doesn’t have to be something complicated.

There are several solutions like simply creating a password protected page or using an existing plugin. But in some cases is just a bit too much.

In this tutorial we’ll learn how to build a simple WordPress Client Portal plugin step by step. And if you aren’t a developer, don’t worry! We’ll also be showing you how to build a client port on your WordPress website without using code!

There are two main things we’ll cover: creating the client portal plugin and exploring what plugins we can integrate it with so we have a complete solution.

Building a customer portal in 3 easy steps

Step 1: Download the Client Portal WordPress plugin

On your WordPress site go to your wp-admin > plugins > add new, search for “client portal” and then click on “install now“.

Step 2: Setting up your client portal

Once activated in your WordPress admin go to users > Client Portal Settings. Here you’ll see all settings available for the plugin.

The settings available are:

  • Page Slug — this is the slug of the private user pages such as example.con/private-page/username.
  • Support Comments — allows you to have communication on the front-end of your site with your clients.
  • Generate Pages — this setting allows you to automatically generate client areas for your existing users with one-click.
  • View all pages — shows the current private pages which can be useful for client management.
  • Restricted Message — The Client Portal plugin is fully white-label allowing you to set custom messages on the front-end of your site for a better user experience.
  • Portal Log In Message — Same as the restricted message, with the difference being this is the message that a client sees when they try and log in.
  • Default Page Content — This is the content that is automatically displayed on the private page. You could include things such as invoices, an area to upload files, links out to project management tools and literally anything you want.

Once you’re happy with all your settings scroll down to the bottom of the page and click “Save Settings”.

Step 3: Onboarding Your Clients

Now you have your client portal setup you need to let your clients know about it! There are two ways you can do this:

  1. Use your favorite email marketing tool such as Mailchimp to send out a newsletter to your clients letting them know about the new client area.
  2. Personally email your clients with a unique email to introduce them to the new client portal, explain how it works and assist them in getting to grips with it. This method helps cultivates your customer relationships and makes them feel more valued.

Congratulations! You’ve now successfully set up a brand-new client portal in just 3 steps.

What to use your client portal for?

There are loads of things you can use your brand new client portal for such as:

  • File uploads.
  • Sharing deliverables — such as completed content work, graphics, zip files etc.
  • Invoicing — upload client invoices to make it easier for both of you to find.
  • Add a contact form — a quick and easy way for your client to reach out if for any reason they lose your contact details.

Building a WordPress Client Portal Plugin

While there seams to be a lot of functionality in this small plugin, most functions are quite small and easy to understand. We’ll go through:

  • creating a plugin
  • building a PHP class that will hold our plugin
  • registering a custom post type so private pages are not mixed with other content
  • automatically create a new private page on new user creation
  • automatically delete private page on user deletion
  • restrict the content if the user doesn’t have permissions to view it
  • adding links to the user private pages in WordPress Dashboard -> Users
  • adding a logout button on the private page for users to use
  • creating the [client-portal] shortcode that redirects users to their private page
  • create settings page for our plugin where we’re editing the default messages
  • add a couple of tweaks like: admin notices, flush permalinks and exclude next and pre navigation from our custom post

If you want to skip this, you can download it from WordPress.org

Get Client Portal Plugin

CREATE A NEW PLUGIN

In WordPress creating a plugin is as simple as creating a .php file with a special header. Here’s how our plugin file will look like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
/**
* Plugin Name: Client Portal
* Plugin URI: http://www.cozmoslabs.com/
* Description: Build a company site with a client portal where clients login and see a restricted-access, personalized page of content with links and downloads.
* Version: 1.0.0
* Author: Cozmoslabs, Madalin Ungureanu, Antohe Cristian
* Author URI: http://www.cozmoslabs.com
* License: GPL2
*/
/* Copyright 2019 Cozmoslabs (www.cozmoslabs.com)
 
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License, version 2, as
published by the Free Software Foundation.
 
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
 
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software Foundation

BUILD A PHP CLASS THAT WILL CONTAIN OUR PLUGIN FUNCTIONS

The main reason for using a PHP class instead of functional programming is to keep everything nice and tidy and limit the risk of naming conflicts.

1
2
3
4
5
6
7
8
9
10
11
12
class CL_Client_Portal
{
private $slug;
private $defaults;
public $options;
 
 
function __construct()
 {
 }
 
}

The __construct() function will contain all our hooks and filters as well as initiating the $slug, $defaults and $options variables that we’ll use throughout our client portal plugin.

INITIALIZE THE DEFAULT VARIABLES PARAMETERS

Any plugin that has options also needs some carefully chosen defaults that will work for the majority of users. We’re doing this inside the __construct() function.

  • $slug – the admin page slug. Also used for the plugin option name in the database
  • $options – if we have user defined options, we’re loading them from the database
  • $defaults – the default settings for the private page slug, restricted message and portal login message
1
2
3
4
5
6
7
$this->slug = 'cp-options';
$this->options = get_option( $this->slug );
$this->defaults = array(
'page-slug' => 'private-page',
'restricted-message' => __( 'You do not have permission to view this page.', 'client-portal' ),
'portal-log-in-message' => __( 'Please log in in order to access the client portal.', 'client-portal' )
);

REGISTER THE PRIVATE PAGE CUSTOM POST TYPE

Using a custom post type instead of normal pages keeps everything clear and organized. The Custom Post Type gets added on the init hook.

The private page slug is dynamic and can be changed by the administrator if needed. We’re also setting it up so it doesn’t show in the WordPress menu since we’ll be adding it under each user in the WordPress -> Users interface.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
 /* register the post type */
add_action( 'init', array( $this, 'cp_create_post_type' ) );
/**
* Function that registers the post type
*/
function cp_create_post_type() {
 
$labels = array(
'name' => _x( 'Private Pages', 'post type general name', 'client-portal' ),
'singular_name' => _x( 'Private Page', 'post type singular name', 'client-portal' ),
'menu_name' => _x( 'Private Page', 'admin menu', 'client-portal' ),
'name_admin_bar' => _x( 'Private Page', 'add new on admin bar', 'client-portal' ),
'add_new' => _x( 'Add New', 'private Page', 'client-portal' ),
'add_new_item' => __( 'Add New Private Page', 'client-portal' ),
'new_item' => __( 'New Private Page', 'client-portal' ),
'edit_item' => __( 'Edit Private Page', 'client-portal' ),
'view_item' => __( 'View Private Page', 'client-portal' ),
'all_items' => __( 'All Private Pages', 'client-portal' ),
'search_items' => __( 'Search Private Pages', 'client-portal' ),
'parent_item_colon' => __( 'Parent Private Page:', 'client-portal' ),
'not_found' => __( 'No Private Pages found.', 'client-portal' ),
'not_found_in_trash' => __( 'No Private Pages found in Trash.', 'client-portal' )
);
 
$args = array(
'labels' => $labels,
'description' => __( 'Description.', 'client-portal' ),
'public' => true,
'publicly_queryable' => true,
'show_ui' => true,
'show_in_menu' => false,
'query_var' => true,
'capability_type' => 'post',
'has_archive' => false,
'hierarchical' => true,
'supports' => array( 'title', 'editor', 'thumbnail' )
);
 
if( !empty( $this->options['page-slug'] ) ){
$args['rewrite'] = array( 'slug' => $this->options['page-slug'] );
}
else{
$args['rewrite'] = array( 'slug' => $this->defaults['page-slug'] );
}
 
register_post_type( 'private-page', $args );
}

CREATING AND DELETING THE PRIVATE PAGES ON USER CREATION AND DELETION

Everytime a new user registers or is created by an administrator, we also need to create it’s private page. Same thing goes on post deletion.

The correlation between a user and a private page is the CPT’s author.

We’re also making use of cp_get_private_page_for_user() function that returns the ID for the private page for a particular user.

    /* action to create a private page when a user registers */
    add_action( 'user_register', array( $this, 'cp_create_private_page' ) );
    /* remove the page when a user is deleted */
    add_action( 'deleted_user', array( $this, 'cp_delete_private_page' ), 10, 2 );
 
    /**
     * Function that creates the private page for a user
     * @param $user_id the id of the user for which to create the page
     */
    function cp_create_private_page( $user_id ){
        /* make sure get_userdata() is available at this point */
        if(is_admin()) require_once( ABSPATH . 'wp-includes/pluggable.php' );
 
        $user = get_userdata( $user_id );
        $display_name = '';
        if( $user ){
            $display_name = ($user->display_name) ? ($user->display_name) : ($user->user_login);
        }
 
        $private_page = array(
            'post_title'    => $display_name,
            'post_status'   => 'publish',
            'post_type'     => 'private-page',
            'post_author'   => $user_id
        );
 
        // Insert the post into the database
        wp_insert_post( $private_page );
    }
 
    /**
     * Function that deletes the private page when the user is deleted
     * @param $id the id of the user which page we are deleting
     * @param $reassign
     */
    function cp_delete_private_page( $id, $reassign ){
        $private_page_id = $this->cp_get_private_page_for_user( $id );
        if( !empty( $private_page_id ) ){
            wp_delete_post( $private_page_id, true );
        }
    }
 
    /**
     * Function that returns the id for the private page for the provided user
     * @param $user_id the user id for which we want to get teh private page for
     * @return mixed
     */
    function cp_get_private_page_for_user( $user_id ){
        $args = array(
            'author'            =>  $user_id,
            'posts_per_page'    =>  1,
            'post_type'         => 'private-page',
        );
        $users_private_pages = get_posts( $args );
 
        if( !empty( $users_private_pages ) ){
            foreach( $users_private_pages as $users_private_page ){
                return $users_private_page->ID;
                break;
            }
        }
    }

RESTRICT THE CONTENT OF THE PRIVATE PAGE IF THE USER DOESN’T HAVE ACCESS TO IT

We need to make sure that logged in users can only access their private page. The only exception is the administrator or users with the capability to delete_users.

We’re blocking access by simply filtering the_content and returning something else instead if the user doesn’t have access or is not logged in.

The error message displayed is taken from the plugin options that we’ll go through a bit down the line.

    /* restrict the content of the page only to the user */
    add_filter( 'the_content', array( $this, 'cp_restrict_content' ) );
    /**
     * Function that restricts the content only to the author of the page
     * @param $content the content of the page
     * @return mixed
     */
    function cp_restrict_content( $content ){
        global $post;
        if( $post->post_type == 'private-page' ){
 
            if( !empty( $this->options['restricted-message'] ) )
                $message = $this->options['restricted-message'];
            else
                $message = $this->defaults['restricted-message'];
 
            if( is_user_logged_in() ){
                if( ( get_current_user_id() == $post->post_author ) || current_user_can('delete_user') ){
                    return $content;
                }
                else return $message;
            }
            else return $message;
 
        }
        return $content;
    }

ADDING LINKS TO THE USER PRIVATE PAGES IN WORDPRESS DASHBOARD -> USERS

Now that we’ve created the private pages for the client portal, we need to allow the administrator to access and edit them.

Since the private pages are user related, we’ll simply list them in the User listing dashboard.

The filter we’re using is user_row_actions.

    /* add a link in the Users List Table in admin area to access the page */
    add_filter( 'user_row_actions', array( $this, 'cp_add_link_to_private_page' ), 10, 2);
    /**
     * Function that adds a link in the user listing in admin area to access the private page
     * @param $actions The actions available on the user listing in admin area
     * @param $user_object The user object
     * @return mixed
     */
    function cp_add_link_to_private_page( $actions, $user_object ){
        $private_page_id = $this->cp_get_private_page_for_user( $user_object->ID );
        if( !empty( $private_page_id ) ){
            $actions['private_page_link'] = "<a class='cp_private_page' href='" . admin_url( "post.php?post=$private_page_id&action=edit") . "'>" . __( 'Private Page', 'client-portal' ) . "</a>";
        }
 
        return $actions;
    }

ADDING A LOGOUT BUTTON ON THE PRIVATE PAGE FOR USERS TO USE

Once a client accesses his private page, it’s important to allow him to logout.

So we’re adding a small logout link as well as his name in case the admin visits that page, so he know what private page he’s looking at.

    /* create client portal extra information */
    add_filter('the_content', array( $this, 'cp_add_private_page_info'));
 
    /**
     * Function that creates a private page extra information div
     * @param $content the content of the private page
     * @return mixed
     */
    function cp_add_private_page_info( $content ){
        global $post;
        if ( is_singular('private-page') && is_user_logged_in() ){
            // logout link
            $logout_link = wp_loginout( home_url(), false);
 
            // author display name. Fallback to username if no display name is set.
            $author_id=$post->post_author;
            $user = get_user_by('id', $author_id);
            $display_name = '';
            if( $user ){
                $display_name = ($user->display_name) ? ($user->display_name) : ($user->user_login);
            }
 
            $extra_info = "<p class='cp-logout' style='border-top: 1px solid #ccc; border-bottom: 1px solid #ccc; padding: 0.5rem 0; text-align: right'> $logout_link - $display_name </p>";
 
            return  $extra_info . $content;
        }
 
        return $content;
    }

CREATING THE [CLIENT-PORTAL] SHORTCODE

This is probably the most important functionality of the entire plugin. Once the user is logged in he needs to be able to access his client portal. By default there’s no way the user or the administrator to create a menu for each individual user.

So instead we’re simply redirecting the user to his individual private page with the use of the [client-portal] shortcode.

Since the shortcode executes in the middle of the page, we can’t really do a PHP redirect without some sort of workaround. So instead we’ve opted for a simple Javascript redirect that should work just fine for the majority of use cases.

    /* create the shortcode for the main page */
    add_shortcode( 'client-portal', array( $this, 'cp_shortcode' ) );
 
    /**
     * Function that creates a shortcode which redirects the user to its private page
     * @param $atts the shortcode attributes
     */
    function cp_shortcode( $atts ){
        if( !is_user_logged_in() ){
            if( !empty( $this->options['portal-log-in-message'] ) )
                $message = $this->options['portal-log-in-message'];
            else
                $message = $this->defaults['portal-log-in-message'];
 
            return $message;
        }
        else{
            $user_id = get_current_user_id();
            $private_page_id = $this->cp_get_private_page_for_user( $user_id );
            $private_page_link = get_permalink( $private_page_id );
            ?>
            <script>
                window.location.replace("<?php echo $private_page_link ?>");
            </script>
        <?php
        }
    }

CREATE SETTINGS PAGE FOR OUR PLUGIN

There are just 3 settings and a button for generating private pages for existing clients. If you want to learn more about the WordPress Settings API this tutorial covers it all.

The function that generates a new private page for existing users is needed when you have clients already registered.

    /* create the settings page */
    add_action( 'admin_menu', array( $this, 'cp_add_settings_page' ) );
    /* register the settings */
    add_action( 'admin_init', array( $this, 'cp_register_settings' ) );
 
 
    /**
     * Function that creates the admin settings page under the Users menu
     */
    function cp_add_settings_page(){
        add_users_page( 'Client Portal Settings', 'Client Portal Settings', 'manage_options', 'client_portal_settings', array( $this, 'cp_settings_page_content' ) );
    }
 
    /**
     * Function that outputs the content for the settings page
     */
    function cp_settings_page_content(){
        /* if the user pressed the generate button then generate pages for existing users */
        if( !empty( $_GET[ 'cp_generate_for_all' ] ) && $_GET[ 'cp_generate_for_all' ] == true ){
            $this->cp_create_private_pages_for_all_users();
        }
 
        ?>
        <div class="wrap form-wrap">
 
            <h2><?php _e( 'Client Portal Settings', 'client-portal'); ?></h2>
 
            <?php settings_errors(); ?>
 
            <form method="POST" action="options.php">
 
                <?php settings_fields( $this->slug ); ?>
 
                <div class="scp-form-field-wrapper">
                    <label class="scp-form-field-label" for="page-slug"><?php echo __( 'Page Slug' , 'client-portal' ) ?></label>
                    <input type="text" class="widefat" id="page-slug" name="cp-options[page-slug]" value="<?php echo ( isset( $this->options['page-slug'] ) ? $this->options['page-slug'] : 'private-page' ); ?>" />
                    <p class="description"><?php echo __( 'The slug of the pages.', 'client-portal' ); ?></p>
                </div>
 
                <div class="scp-form-field-wrapper">
                    <label class="scp-form-field-label"><?php echo __( 'Generate pages' , 'client-portal' ) ?></label>
                    <a class="button" href="<?php echo add_query_arg( 'cp_generate_for_all', 'true', admin_url("/users.php?page=client_portal_settings") ) ?>"><?php _e( 'Generate pages for existing users' ); ?></a>
                    <p class="description"><?php echo __( 'Generate pages for already existing users.', 'client-portal' ); ?></p>
                </div>
 
                <div class="scp-form-field-wrapper">
                    <label class="scp-form-field-label" for="restricted-message"><?php echo __( 'Restricted Message' , 'client-portal' ) ?></label>
                    <textarea name="cp-options[restricted-message]" id="restricted-message" class="widefat"><?php echo ( isset( $this->options['restricted-message'] ) ? $this->options['restricted-message'] : $this->defaults['restricted-message'] ); ?></textarea>
                    <p class="description"><?php echo __( 'The default message showed on pages that are restricted.', 'client-portal' ); ?></p>
                </div>
 
                <div class="scp-form-field-wrapper">
                    <label class="scp-form-field-label" for="portal-log-in-message"><?php echo __( 'Portal Log In Message' , 'client-portal' ) ?></label>
                    <textarea name="cp-options[portal-log-in-message]" id="portal-log-in-message" class="widefat"><?php echo ( isset( $this->options['portal-log-in-message'] ) ? $this->options['portal-log-in-message'] : $this->defaults['portal-log-in-message'] ); ?></textarea>
                    <p class="description"><?php echo __( 'The default message showed on pages that are restricted.', 'client-portal' ); ?></p>
                </div>
 
                <?php submit_button( __( 'Save Settings', 'client_portal_settings' ) ); ?>
 
            </form>
        </div>
    <?php
    }
 
    /**
     * Function that registers the settings for the settings page with the Settings API
     */
    public function cp_register_settings() {
        register_setting( $this->slug, $this->slug );
    }
 
    /**
     *  Function that creates private pages for all existing users
     */
    function cp_create_private_pages_for_all_users(){
        $all_users = get_users( array(  'fields' => array( 'ID' ) ) );
        if( !empty( $all_users ) ){
            foreach( $all_users as $user ){
                $args = array(
                    'author'            =>  $user->ID, // I could also use $user_ID, right?
                    'posts_per_page'    => 1,
                    'post_type'         => 'private-page',
                );
                $users_private_pages = get_posts( $args );
                if( empty( $users_private_pages ) ) {
                    $this->cp_create_private_page( $user->ID );
                }
 
            }
        }
    }

A FEW MORE TWEAKS FOR A BETTER CLIENT PORTAL PLUGIN

In order to take this a bit further, we’ll have a style the settings page a bit, add some admin notices, regenerate the permalinks if we’re changing the private page slug and make sure we don’t get next and previous posts navigation on our private pages.

    /* show notices on the admin settings page */
    add_action( 'admin_notices', array( $this, 'cp_admin_notices' ) );
    // Enqueue scripts on the admin side
    add_action( 'admin_enqueue_scripts', array( $this, 'cp_enqueue_admin_scripts' ) );
    /* flush the rewrite rules when settings saved in case page slug was changed */
    add_action('init', array( $this, 'cp_flush_rules' ), 20 );
 
    /* make sure we don't have post navigation on the private pages */
    add_filter( "get_previous_post_where", array( $this, 'cp_exclude_from_post_navigation' ), 10, 5 );
    add_filter( "get_next_post_where", array( $this, 'cp_exclude_from_post_navigation' ), 10, 5 );
 
   /**
     * Function that creates the notice messages on the settings page
     */
    function cp_admin_notices(){
        if( !empty( $_GET['page'] ) && $_GET['page'] == 'client_portal_settings' ) {
            if( !empty( $_GET['cp_generate_for_all'] ) && $_GET['cp_generate_for_all'] == true ) {
                ?>
                <div class="notice notice-success is-dismissible">
                    <p><?php _e( 'Successfully generated private pages for existing users.', 'client-portal'); ?></p>
                </div>
                <?php
                if( !empty( $_REQUEST['settings-updated'] ) && $_GET['settings-updated'] == 'true' ) {
                    ?>
                    <div class="notice notice-success is-dismissible">
                        <p><?php _e( 'Settings saved.', 'client-portal'); ?></p>
                    </div>
                <?php
                }
            }
        }
    }
 
    /**
     * Function that enqueues the scripts on the admin settings page
     */
    function cp_enqueue_admin_scripts() {
        if( !empty( $_GET['page'] ) && $_GET['page'] == 'client_portal_settings' )
            wp_enqueue_style( 'cp_style-back-end', plugins_url( 'assets/style.css', __FILE__ ) );
    }
 
    /**
     * Function that flushes the rewrite rules when we save the settings page
     */
    function cp_flush_rules(){
        if( isset( $_GET['page'] ) && $_GET['page'] == 'client_portal_settings' && isset( $_REQUEST['settings-updated'] ) && $_REQUEST['settings-updated'] == 'true' ) {
            flush_rewrite_rules(false);
        }
    }
 
 
    /**
     * Function that filters the WHERE clause in the select for adjacent posts so we exclude private pages
     * @param $where
     * @param $in_same_term
     * @param $excluded_terms
     * @param $taxonomy
     * @param $post
     * @return mixed
     */
    function cp_exclude_from_post_navigation( $where, $in_same_term, $excluded_terms, $taxonomy, $post ){
        if( $post->post_type == 'private-page' ){
            $where = str_replace( "'private-page'", "'do not show this'", $where );
        }
        return $where;
    }
 
    /**
     * Function that returns the id for the private page for the provided user
     * @param $user_id the user id for which we want to get teh private page for
     * @return mixed
     */

CREATE A NEW OBJECT

The last thing we’re doing is create a new object with our CL_Client_Portal class.

1
$CP_Object = new CL_Client_Portal();

ENTIRE PLUGIN

After all this, the entire plugin looks like this:

<?php
/**
 * Plugin Name: Client Portal
 * Plugin URI: http://www.cozmoslabs.com/
 * Description:  Build a company site with a client portal where clients login and see a restricted-access, personalized page of content with links and downloads.
 * Version: 1.0.0
 * Author: Cozmoslabs, Madalin Ungureanu, Antohe Cristian
 * Author URI: http://www.cozmoslabs.com
 * License: GPL2
 */
/*  Copyright 2015 Cozmoslabs (www.cozmoslabs.com)
 
    This program is free software; you can redistribute it and/or modify
    it under the terms of the GNU General Public License, version 2, as
    published by the Free Software Foundation.
 
    This program is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU General Public License for more details.
 
    You should have received a copy of the GNU General Public License
    along with this program; if not, write to the Free Software
    Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
*/
/*
* Define plugin path
*/
 
class CL_Client_Portal
{
    private $slug;
    private $defaults;
    public $options;
 
 
    function __construct()
    {
        $this->slug = 'cp-options';
        $this->options = get_option( $this->slug );
        $this->defaults = array(
                                'page-slug' => 'private-page',
                                'restricted-message' => __( 'You do not have permission to view this page.', 'client-portal' ),
                                'portal-log-in-message' => __( 'Please log in in order to access the client portal.', 'client-portal' )
                                );
 
        /* register the post type */
        add_action( 'init', array( $this, 'cp_create_post_type' ) );
        /* action to create a private page when a user registers */
        add_action( 'user_register', array( $this, 'cp_create_private_page' ) );
        /* remove the page when a user is deleted */
        add_action( 'deleted_user', array( $this, 'cp_delete_private_page' ), 10, 2 );
        /* restrict the content of the page only to the user */
        add_filter( 'the_content', array( $this, 'cp_restrict_content' ) );
        /* add a link in the Users List Table in admin area to access the page */
        add_filter( 'user_row_actions', array( $this, 'cp_add_link_to_private_page' ), 10, 2);
 
        /* create client portal extra information */
        add_filter('the_content', array( $this, 'cp_add_private_page_info'));
 
        /* create the shortcode for the main page */
        add_shortcode( 'client-portal', array( $this, 'cp_shortcode' ) );
 
        /* create the settings page */
        add_action( 'admin_menu', array( $this, 'cp_add_settings_page' ) );
        /* register the settings */
        add_action( 'admin_init', array( $this, 'cp_register_settings' ) );
        /* show notices on the admin settings page */
        add_action( 'admin_notices', array( $this, 'cp_admin_notices' ) );
        // Enqueue scripts on the admin side
        add_action( 'admin_enqueue_scripts', array( $this, 'cp_enqueue_admin_scripts' ) );
        /* flush the rewrite rules when settings saved in case page slug was changed */
        add_action('init', array( $this, 'cp_flush_rules' ), 20 );
 
        /* make sure we don't have post navigation on the private pages */
        add_filter( "get_previous_post_where", array( $this, 'cp_exclude_from_post_navigation' ), 10, 5 );
        add_filter( "get_next_post_where", array( $this, 'cp_exclude_from_post_navigation' ), 10, 5 );
 
    }
 
    /**
     * Function that registers the post type
     */
    function cp_create_post_type() {
 
        $labels = array(
            'name'               => _x( 'Private Pages', 'post type general name', 'client-portal' ),
            'singular_name'      => _x( 'Private Page', 'post type singular name', 'client-portal' ),
            'menu_name'          => _x( 'Private Page', 'admin menu', 'client-portal' ),
            'name_admin_bar'     => _x( 'Private Page', 'add new on admin bar', 'client-portal' ),
            'add_new'            => _x( 'Add New', 'private Page', 'client-portal' ),
            'add_new_item'       => __( 'Add New Private Page', 'client-portal' ),
            'new_item'           => __( 'New Private Page', 'client-portal' ),
            'edit_item'          => __( 'Edit Private Page', 'client-portal' ),
            'view_item'          => __( 'View Private Page', 'client-portal' ),
            'all_items'          => __( 'All Private Pages', 'client-portal' ),
            'search_items'       => __( 'Search Private Pages', 'client-portal' ),
            'parent_item_colon'  => __( 'Parent Private Page:', 'client-portal' ),
            'not_found'          => __( 'No Private Pages found.', 'client-portal' ),
            'not_found_in_trash' => __( 'No Private Pages found in Trash.', 'client-portal' )
        );
 
        $args = array(
            'labels'             => $labels,
            'description'        => __( 'Description.', 'client-portal' ),
            'public'             => true,
            'publicly_queryable' => true,
            'show_ui'            => true,
            'show_in_menu'       => false,
            'query_var'          => true,
            'capability_type'    => 'post',
            'has_archive'        => false,
            'hierarchical'       => true,
            'supports'           => array( 'title', 'editor', 'thumbnail' )
        );
 
        if( !empty( $this->options['page-slug'] ) ){
            $args['rewrite'] = array( 'slug' => $this->options['page-slug'] );
        }
        else{
            $args['rewrite'] = array( 'slug' => $this->defaults['page-slug'] );
        }
 
        register_post_type( 'private-page', $args );
    }
 
    /**
     * Function that creates the private page for a user
     * @param $user_id the id of the user for which to create the page
     */
    function cp_create_private_page( $user_id ){
        /* make sure get_userdata() is available at this point */
        if(is_admin()) require_once( ABSPATH . 'wp-includes/pluggable.php' );
 
        $user = get_userdata( $user_id );
        $display_name = '';
        if( $user ){
            $display_name = ($user->display_name) ? ($user->display_name) : ($user->user_login);
        }
 
        $private_page = array(
            'post_title'    => $display_name,
            'post_status'   => 'publish',
            'post_type'     => 'private-page',
            'post_author'   => $user_id
        );
 
        // Insert the post into the database
        wp_insert_post( $private_page );
    }
 
    /**
     * Function that deletes the private page when the user is deleted
     * @param $id the id of the user which page we are deleting
     * @param $reassign
     */
    function cp_delete_private_page( $id, $reassign ){
        $private_page_id = $this->cp_get_private_page_for_user( $id );
        if( !empty( $private_page_id ) ){
            wp_delete_post( $private_page_id, true );
        }
    }
 
    /**
     * Function that restricts the content only to the author of the page
     * @param $content the content of the page
     * @return mixed
     */
    function cp_restrict_content( $content ){
        global $post;
        if( $post->post_type == 'private-page' ){
 
            if( !empty( $this->options['restricted-message'] ) )
                $message = $this->options['restricted-message'];
            else
                $message = $this->defaults['restricted-message'];
 
            if( is_user_logged_in() ){
                if( ( get_current_user_id() == $post->post_author ) || current_user_can('delete_user') ){
                    return $content;
                }
                else return $message;
            }
            else return $message;
 
        }
        return $content;
    }
 
    /**
     * Function that adds a link in the user listing in admin area to access the private page
     * @param $actions The actions available on the user listing in admin area
     * @param $user_object The user object
     * @return mixed
     */
    function cp_add_link_to_private_page( $actions, $user_object ){
        $private_page_id = $this->cp_get_private_page_for_user( $user_object->ID );
        if( !empty( $private_page_id ) ){
            $actions['private_page_link'] = "<a class='cp_private_page' href='" . admin_url( "post.php?post=$private_page_id&action=edit") . "'>" . __( 'Private Page', 'client-portal' ) . "</a>";
        }
 
        return $actions;
    }
 
    /**
     * Function that creates a private page extra information div
     * @param $content the content of the private page
     * @return mixed
     */
    function cp_add_private_page_info( $content ){
        global $post;
        if ( is_singular('private-page') && is_user_logged_in() ){
            // logout link
            $logout_link = wp_loginout( home_url(), false);
 
            // author display name. Fallback to username if no display name is set.
            $author_id=$post->post_author;
            $user = get_user_by('id', $author_id);
            $display_name = '';
            if( $user ){
                $display_name = ($user->display_name) ? ($user->display_name) : ($user->user_login);
            }
 
            $extra_info = "<p class='cp-logout' style='border-top: 1px solid #ccc; border-bottom: 1px solid #ccc; padding: 0.5rem 0; text-align: right'> $logout_link - $display_name </p>";
 
            return  $extra_info . $content;
        }
 
        return $content;
    }
 
    /**
     * Function that creates a shortcode which redirects the user to its private page
     * @param $atts the shortcode attributes
     */
    function cp_shortcode( $atts ){
        if( !is_user_logged_in() ){
            if( !empty( $this->options['portal-log-in-message'] ) )
                $message = $this->options['portal-log-in-message'];
            else
                $message = $this->defaults['portal-log-in-message'];
 
            return $message;
        }
        else{
            $user_id = get_current_user_id();
            $private_page_id = $this->cp_get_private_page_for_user( $user_id );
            $private_page_link = get_permalink( $private_page_id );
            ?>
            <script>
                window.location.replace("<?php echo $private_page_link ?>");
            </script>
        <?php
        }
    }
 
    /**
     * Function that creates the admin settings page under the Users menu
     */
    function cp_add_settings_page(){
        add_users_page( 'Client Portal Settings', 'Client Portal Settings', 'manage_options', 'client_portal_settings', array( $this, 'cp_settings_page_content' ) );
    }
 
    /**
     * Function that outputs the content for the settings page
     */
    function cp_settings_page_content(){
        /* if the user pressed the generate button then generate pages for existing users */
        if( !empty( $_GET[ 'cp_generate_for_all' ] ) && $_GET[ 'cp_generate_for_all' ] == true ){
            $this->cp_create_private_pages_for_all_users();
        }
 
        ?>
        <div class="wrap form-wrap">
 
            <h2><?php _e( 'Client Portal Settings', 'client-portal'); ?></h2>
 
            <?php settings_errors(); ?>
 
            <form method="POST" action="options.php">
 
                <?php settings_fields( $this->slug ); ?>
 
                <div class="scp-form-field-wrapper">
                    <label class="scp-form-field-label" for="page-slug"><?php echo __( 'Page Slug' , 'client-portal' ) ?></label>
                    <input type="text" class="widefat" id="page-slug" name="cp-options[page-slug]" value="<?php echo ( isset( $this->options['page-slug'] ) ? $this->options['page-slug'] : 'private-page' ); ?>" />
                    <p class="description"><?php echo __( 'The slug of the pages.', 'client-portal' ); ?></p>
                </div>
 
                <div class="scp-form-field-wrapper">
                    <label class="scp-form-field-label"><?php echo __( 'Generate pages' , 'client-portal' ) ?></label>
                    <a class="button" href="<?php echo add_query_arg( 'cp_generate_for_all', 'true', admin_url("/users.php?page=client_portal_settings") ) ?>"><?php _e( 'Generate pages for existing users' ); ?></a>
                    <p class="description"><?php echo __( 'Generate pages for already existing users.', 'client-portal' ); ?></p>
                </div>
 
                <div class="scp-form-field-wrapper">
                    <label class="scp-form-field-label" for="restricted-message"><?php echo __( 'Restricted Message' , 'client-portal' ) ?></label>
                    <textarea name="cp-options[restricted-message]" id="restricted-message" class="widefat"><?php echo ( isset( $this->options['restricted-message'] ) ? $this->options['restricted-message'] : $this->defaults['restricted-message'] ); ?></textarea>
                    <p class="description"><?php echo __( 'The default message showed on pages that are restricted.', 'client-portal' ); ?></p>
                </div>
 
                <div class="scp-form-field-wrapper">
                    <label class="scp-form-field-label" for="portal-log-in-message"><?php echo __( 'Portal Log In Message' , 'client-portal' ) ?></label>
                    <textarea name="cp-options[portal-log-in-message]" id="portal-log-in-message" class="widefat"><?php echo ( isset( $this->options['portal-log-in-message'] ) ? $this->options['portal-log-in-message'] : $this->defaults['portal-log-in-message'] ); ?></textarea>
                    <p class="description"><?php echo __( 'The default message showed on pages that are restricted.', 'client-portal' ); ?></p>
                </div>
 
                <?php submit_button( __( 'Save Settings', 'client_portal_settings' ) ); ?>
 
            </form>
        </div>
    <?php
    }
 
    /**
     * Function that registers the settings for the settings page with the Settings API
     */
    public function cp_register_settings() {
        register_setting( $this->slug, $this->slug );
    }
 
    /**
     * Function that creates the notice messages on the settings page
     */
    function cp_admin_notices(){
        if( !empty( $_GET['page'] ) && $_GET['page'] == 'client_portal_settings' ) {
            if( !empty( $_GET['cp_generate_for_all'] ) && $_GET['cp_generate_for_all'] == true ) {
                ?>
                <div class="notice notice-success is-dismissible">
                    <p><?php _e( 'Successfully generated private pages for existing users.', 'client-portal'); ?></p>
                </div>
                <?php
                if( !empty( $_REQUEST['settings-updated'] ) && $_GET['settings-updated'] == 'true' ) {
                    ?>
                    <div class="notice notice-success is-dismissible">
                        <p><?php _e( 'Settings saved.', 'client-portal'); ?></p>
                    </div>
                <?php
                }
            }
        }
    }
 
    /**
     * Function that enqueues the scripts on the admin settings page
     */
    function cp_enqueue_admin_scripts() {
        if( !empty( $_GET['page'] ) && $_GET['page'] == 'client_portal_settings' )
            wp_enqueue_style( 'cp_style-back-end', plugins_url( 'assets/style.css', __FILE__ ) );
    }
 
    /**
     * Function that flushes the rewrite rules when we save the settings page
     */
    function cp_flush_rules(){
        if( isset( $_GET['page'] ) && $_GET['page'] == 'client_portal_settings' && isset( $_REQUEST['settings-updated'] ) && $_REQUEST['settings-updated'] == 'true' ) {
            flush_rewrite_rules(false);
        }
    }
 
 
    /**
     * Function that filters the WHERE clause in the select for adjacent posts so we exclude private pages
     * @param $where
     * @param $in_same_term
     * @param $excluded_terms
     * @param $taxonomy
     * @param $post
     * @return mixed
     */
    function cp_exclude_from_post_navigation( $where, $in_same_term, $excluded_terms, $taxonomy, $post ){
        if( $post->post_type == 'private-page' ){
            $where = str_replace( "'private-page'", "'do not show this'", $where );
        }
        return $where;
    }
 
    /**
     * Function that returns the id for the private page for the provided user
     * @param $user_id the user id for which we want to get teh private page for
     * @return mixed
     */
    function cp_get_private_page_for_user( $user_id ){
        $args = array(
            'author'            =>  $user_id,
            'posts_per_page'    =>  1,
            'post_type'         => 'private-page',
        );
        $users_private_pages = get_posts( $args );
 
        if( !empty( $users_private_pages ) ){
            foreach( $users_private_pages as $users_private_page ){
                return $users_private_page->ID;
                break;
            }
        }
    }
 
    /**
     * Function that returns all the private pages post objects
     * @return array
     */
    function cp_get_all_private_pages(){
        $args = array(
            'posts_per_page'    =>  -1,
            'numberposts'       =>   -1,
            'post_type'         => 'private-page',
        );
 
        $users_private_pages = get_posts( $args );
        return $users_private_pages;
    }
 
    /**
     *  Function that creates private pages for all existing users
     */
    function cp_create_private_pages_for_all_users(){
        $all_users = get_users( array(  'fields' => array( 'ID' ) ) );
        if( !empty( $all_users ) ){
            foreach( $all_users as $user ){
                $args = array(
                    'author'            =>  $user->ID, // I could also use $user_ID, right?
                    'posts_per_page'    => 1,
                    'post_type'         => 'private-page',
                );
                $users_private_pages = get_posts( $args );
                if( empty( $users_private_pages ) ) {
                    $this->cp_create_private_page( $user->ID );
                }
 
            }
        }
    }
 
}
 
$CP_Object = new CL_Client_Portal();

You can download the entire plugin from WordPress.org

Get Client Portal Plugin

Moving forward

The reason we were able to make this work in ~430 lines of code (~30% comments) is simply because we can offload anything else we might need to existing free plugins:

Obviously there are other plugins that do the above so any login, membership or custom fields plugin will allow you to extend the Client Portal plugin to fit your needs exactly.

Get Client Portal Plugin

Subscribe to get early access

to new plugins, discounts and brief updates about what's new with Cozmoslabs!

11 thoughts on “How to build a WordPress Client Portal plugin

  1. Hi,

    Thanks so much for this tutorial and plugin. I use it on my website and I love it. I have one small question. Is there a way I can have pre-entered data on each private page when a new one is created for each user. I use my private pages as a transcript and there is a specific template that needs to be inserted into each page, how would I go about doing that?

    Thanks

  2. Please I want to add a welcome note and other related content/data to be displayed immediately a user private page is created. Please guide me on how to do this. I love you for what you are doing, God will reward abundantly. More power to your elbow.

  3. Good day! Nice addon, but the user can change the content of other pages. How can I restrict my rights so that the user can only edit their own page?

    1. Hello Vasily,

      The idea of this plugin is not to let the user edit this page, but only the admin to place private content, specific to that user on it.

  4. I too would like to be able to add a generic text that would automatically go on all newly created private pages. Is this possible?
    Thank you

    1. Hello Charles,

      Yes, this is possible. After installing, go to Users -> Client Portal Settings and you will have a Content Box that lets you do this.

  5. so i set this up as a widget on the side of my site and every time i log in using my “test user” it redirects to a wordpress log in and not to their private page. I can’t get it to work

  6. Please delete my reply to the post above, I didn’t mean to reply to the previous question, I was asking a new question:

    I’m wondering what the logic/thought process is behind the decision to hide the WP Admin menu for the custom post type?

    All this does is make it harder to reach the private pages in the admin since we need to open users, find the user, then click on the private page link.

    Surely it would be easier to simply have the private pages post type as a menu item so we can go there directly?

    I’m very interested in the pro version of the tools, but this is one of the decisions in the admin setup that is making me scratch my head a bit and hesitate.

    1. Hi,

      That’s because you reach them from the backend user listing. They are tied to a particular user anyway. So listing them also as a top level menu item would just clutter the UI. At least that’s the reasoning behind it.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.