Open jaswrks opened 9 years ago
This is a great idea, but there are several things that need to be taken into account at the same time.
First, I think the basic User-Specific Page should be a page, rather than a post, so that it is then possible to have sub-pages that are automatically also specific to the user.
Second, the (parent) User-Specific Page should be the page to which the user is redirected on login. This is clearly the idea of the Special Redirection URLs and, especially because of the next point, it is very important.
Third, it should not be possible for one user to learn of the existence of another user's User-Specific Page (or sub-pages). This means that the User-Specific Page should not, for example, show up in menus or searches. A "Your Page" type of entry in a menu would be useful, of course, provided that it would automatically redirect to the user's own User-Specific Page.
Fourth, there needs to be a default Login Welcome page for users who do not have their own User-Specific Page. (I don't know what currently happens when a Special Redirection URL is set and a member logs in who doesn't have such a URL/page.)
Oh, yes! Yes! All good points, @KTS915.
@KTS915 Great feedback and ideas! TY ~ I'll scan over your list as work continues on this.
@raamdev @KTS915 I'm trying to come up with some ideas for how we might implement this in a way that makes it easy for a site owner to build and customize pages for specific users whenever the site is being used almost exclusively as a client portal.
WP Snippets comes to mind as something that we might harness for this. Thoughts? https://wordpress.org/plugins/wp-snippets/
@jaswsinc The two things that I feel would greatly improve the experience of setting up a Client Portal would be the following:
[s2Member-CP-Page-List /]
shortcode, which would effectively do the same thing (list all of the pages that "belong" to the current user).[s2File /]
shortcode and explanations of inline vs non-inline would be useful. Also, explaining the file sharing limitations would be useful (e.g., "Can users share the links with friends? How can I allow that if I want to?").Keep in mind that most Client Portal scenarios that I have seen are really simple and most of the features in s2Member are overkill for those scenarios. The things I described above might sound like they "cripple" things a bit, but that's actually the whole point.
Whenever I've had to explain how a site owner could use s2Member to create a Client Portal, the most challenging things were explaining how to use a replacement code to create a dynamic LWP, and then explain that a page with a Slug that matches the username of the user must be created. Then I have to explain that listing additional pages the user has access to is an entirely manual process, as is restricting access to a page to one user (using Custom Capabilities).
The flexibility and power offered by s2Member simply makes the process of setting up a Client Portal (which is often a very simple, one-user-one-page + list of file downloads or other pages the user has access to) a lot more work than it really needs to be at the moment, and the above things, plus a new video and/or KB Article(s) would greatly improve the process.
@jaswsinc, @raamdev,
I agree with almost all of what @raamdev has said. Client Portals are generally much simpler than the sorts of sites for which s2Member was originally designed. But they also have some very specific requirements.
mu-plugins
code here (https://github.com/websharks/s2member-kb/issues/131) gets that job done nicely. But it would be even better if activation of Raam's "Enable Client Portal Mode" would bring that code into play automatically without the need for an mu-plugin
.My reason for disagreeing is that I think this could get extremely confusing. Instead, I think what's needed is to make use of the fact that pages can have parents and children. So client Smithco & Sons gets something like mysite.com/smithco/
as its LWP, and all child pages of mysite.com/smithco/
then automatically "belong" to Smithco.
This way everything is standardized. The parent "root" page is always a client's LWP. And every other page belonging to that client is a child (or, feasibly, a grandchild) of this (grand)parent.
Obviously, s2Member is already well equipped for storing such files. Presumably, though, another consequence of "Enable Client Portal Mode" should be that the slug of the parent page should become a ccap that dictates the relevant s2member-files
sub-folder for that client.
But there still needs to be some way of enabling the client to find all these files. The simplest way would, obviously, require just a shortcode. But there would need to be some options here, such as ordering by date, or grouping by type. And, of course, grouping by type means being able to store by type too.
I think menus are trickier. One option would be to do nothing here. That would be fine for a site owner with only a few clients. But it could get out of hand for someone with lots of clients.
I am not sure if this is where WP Snippets might come in handy. Perhaps it could provide (a) a new menu area at the top of each client's pages, and (b) some way of making just the client's own pages available to be included in such a menu.
Maybe -- and I'm not sure about this -- there could even be an option for the site owner to set up a default set of pages (e.g. LWP, files page, profile page, payments and invoices page) and for them to form a default menu.
Hmm, evidently I need to find out how to continue previous enumeration on here!
Woohoo! Wow, that's some great insight from both of you. I'm soaking some of this in at the moment, but I'll see what I can get started with so we can try to move this forward. I will post some follow-ups soon. Thanks so much :-)
This sounds great and is just what I need for my site. Some thoughs from me:
@mitdrivhus,
I like the idea of a "a menu item called Clients" for all the reasons you give. It could also, perhaps, have a box where the admin can choose the slug/ccap -- which, when saved, could also auto-generate the equivalent s2member-files subfolder.
In this thread (https://wordpress.org/support/topic/custom-capabilities-not-working-1?replies=10#post-7859605) caseyfriday apparently both creates a ccap and a custom post type when a user is created.
Presumably, a similar approach could be adopted for a client portal, albeit setting both a ccap and a page protected by that ccap when a user is created.
Just come across this plugin and thought you might be interested to see how it works: https://wordpress.org/plugins/client-portal/
In fact, all it does is create a simple means of generating private pages for users, and then offers a shortcode to be placed on a page which redirects each user to his or her own private page.
The shortcode redirection is too slow, and you get a flash of the original page before the redirection, while the plugin also offers no means of creating sub-pages for each user.
But the method of generating one private page for each user works well.
I have been thinking about this a lot recently because I'd really like to build some sites with this facility. Re-reading this discussion, I think we need to take things in stages, and just start with the basics. So I've been trying to work out what I might suggest that @jaswsinc, @raamdev and @patdumond might be able to create right now. I suggest the following things:
smithco
causes the ccap smithco
to be generated.smithco
is automatically given the ccap smithco
.What I think this then produces is the ability to have users automatically redirected to their own LWP by means of the %%current_user_nicename%%
and the using the Special Redirection URL facility. It also keeps others out, and enables the site admin to create sub-pages for that particular member that will be automatically available only to that member.
In addition, sub-folders within the s2member-files
folder will automatically work. So the subfolder smithco
within the the s2member-files
folder could automatically be used to store files for that member. Whether s2Member needs to generate the sub-folder automatically is open to debate, but the point is that the facility to protect that member's files will be generated as soon as the member is created.
Does this sound doable in the short term?
@mitdrivhus, @ethanpil: would this work for you?
Yep. It sure sounds like a solution 👍🏼 …
Coming back to this again, I am trying to create an mu-plugin
that will achieve this with the current functionality of s2Member and with a minimum of manual intervention.
The only bits of the puzzle that I'm missing are these:
Having first obtained the page ID, I have tried this:
add_post_meta( $page_id, 's2member_ccaps_req', 'name-of-ccap');
But, while that adds the ccap to the page's metabox field, it doesn't add it to the admin's list of pages, and it doesn't enter the ccap into the database in a form that protects the page.
Any ideas?
Try it like this:
add_post_meta( $page_id, 's2member_ccaps_req', ['name-of-ccap']);
That works perfectly. Thank you, Jason! (Amazing what a pair of brackets can do, and I've no idea what role they perform, so I'll need to look up that syntax.)
Assuming that I can do the same thing using when I create a sub-page, that means the only thing left is to obtain the protections from the parent page. This is what I have so far:
function inherit_parent_protections( $post_id, $post, $update ) {
if ( get_post_type() == 'page' ) {
// Check if page has parent
if ( $post->post_parent > 0 ) {
$parent = wp_get_post_parent_id( $post_id );
$ccaps = get_post_meta( $parent, 's2member_ccaps_req', true );
add_post_meta( $post_id, 's2member_ccaps_req', [$ccaps] );
}
}
}
add_action( 'save_post', 'inherit_parent_protections', 10, 3 );
The problem here seems to be that I'm just not getting the protections of the parent, so I can't apply them to the new page.
(Updated, but it makes no difference to the result)
Aha, got it! It turned out that (a) I needed to use the wp_insert_post
hook, and (b) because I am getting the ccaps from postmeta, I don't need to put them within brackets this time. So, in case anyone cares, this function now looks like this:
function inherit_parent_protections( $post_id, $post, $update ) {
$post_type = get_post_type( $post_id );
if ( "page" != $post_type ) {
return;
}
// Check if page has parent
if ( $post->post_parent > 0 ) {
$parent = wp_get_post_parent_id( $post_id );
$ccaps = get_post_meta( $parent, 's2member_ccaps_req', true );
add_post_meta( $post_id, 's2member_ccaps_req', $ccaps );
}
}
add_action( 'wp_insert_post', 'inherit_parent_protections', 10, 3 );
I'm pasting the full code below, and would really appreciate comments and suggestions. I have tried it on one site, where everything now works as I expected. (There was an issue before, which I've now resolved, and I have amended the code below accordingly.) Whether it meets others' expectations, I'd love to hear.
The code presupposes a regular install of s2Member with a regular Login Welcome Page (i.e. not a Special Redirection URL). It also presupposes that all clients are given one role. I have set it as s2member_level 2
but, any role is possible so long as the role is kept consistent throughout the code. What this code is then supposed to do is as follows:
s2member-files
folder that is protected with a ccap equivalent to the client's username.So here's the code (now revised so that it will also work when a current member becomes a client, and also to ensure that pages and folders are not created when non-clients are added to the site):
<?php
/* AUTO-REDIRECT TO BYPASS MOP */
add_action( 'template_redirect', function () {
if ( !is_page( S2MEMBER_MEMBERSHIP_OPTIONS_PAGE_ID ) ) {
return; // nothing to do if no initial redirection to MOP
}
else if ( !empty( $_REQUEST["_s2member_vars"] ) ) {
@list ( $restriction_type, $requirement_type, $requirement_type_value, $seeking_type, $seeking_type_value, $seeking_uri ) = explode ( "..", stripslashes( ( string )$_REQUEST["_s2member_vars"] ) );
}
if ( !empty( $seeking_uri ) ) {
$URI = base64_decode( $seeking_uri );
}
if ( !is_user_logged_in() && !empty( $URI ) ) {
$redirect = home_url( '/wp-login.php' ); // login page: change as required
$redirect = add_query_arg( 'redirect_to', urlencode( $URI ), $redirect );
wp_redirect( $redirect ); // perform the redirection
exit;
}
});
function s2_redirect( $redirect, $vars = array() ) {
if ( ( isset( $_GET['action'] ) && $_GET['action'] != 'logout' ) || ( isset( $_POST['login_location'] ) && !empty( $_POST['login_location'] ) ) ) {
$redirect = $_SERVER['HTTP_REFERER'];
return $redirect;
}
}
add_filter( 'ws_plugin__s2member_login_redirect', 's2_redirect', 10, 2 );
/* CREATE CLIENT PORTAL */
function create_client_portal( $user_id, $new_role, $old_role ) {
if ( !$user_id > 0 ) {
return;
}
if ( $new_role == 's2member_level2' ) {
$data = get_userdata( $user_id );
$username = strtolower( $data->user_login );
// Create client portal page
$client_page = array(
'post_title' => 'Client Portal for ' . trim( $data->first_name . ' ' . $data->last_name ),
'post_name' => $username,
'post_type' => 'page',
'post_content' => 'Welcome to your portal. Any messages for you will be posted here.',
'post_status' => 'publish',
'comment_status' => 'closed',
'ping_status' => 'closed',
'post_author' => 3 // ID of administrator: change as necessary
);
$page_id = wp_insert_post( $client_page ); // insert into database and get page ID
// Give client a custom capability equivalent to their username
$ccap_username = 'access_s2member_ccap_' . $username;
$user = new WP_User( $user_id );
$user->add_cap( $ccap_username );
// Require the same custom capability to access client portal page
add_post_meta( $page_id, 's2member_ccaps_req', [$username] );
// Create file storage folder requiring the same custom capability
$url = WP_PLUGIN_DIR . '/s2member-files/access-s2member-ccap-' . $username;
wp_mkdir_p( $url );
}
}
add_action( 'set_user_role', 'create_client_portal', 10, 3 );
/* REDIRECT CLIENTS TO OWN CLIENT PAGE WHEN NOT SEEKING SPECIFIC URL */
function client_redirect_to_portal_page() {
if ( !is_page( S2MEMBER_LOGIN_WELCOME_PAGE_ID ) ) {
return; // nothing to do if no initial attempt to go to Login Welcome Page
}
if ( current_user_is( 's2member_level2' ) ) { // must match clients user role
$current_user = wp_get_current_user();
$username = $current_user->user_login;
$page = get_page_by_path( $username, OBJECT );
if ( isset( $page ) ) {
$redirect = get_permalink( $page );
wp_redirect( $redirect ); // perform the redirection
exit;
}
}
}
add_action( 'template_redirect', 'client_redirect_to_portal_page' );
/* MAKE SUB-PAGE INHERIT CCAPS PROTECTION OF PARENT */
function inherit_parent_protections( $post_id, $post, $update ) {
$post_type = get_post_type( $post_id );
if ( 'page' != $post_type ) {
return;
}
if ( $post->post_parent > 0 ) { // check if page has parent
$parent = wp_get_post_parent_id( $post_id );
$ccaps = get_post_meta( $parent, 's2member_ccaps_req', true );
add_post_meta( $post_id, 's2member_ccaps_req', $ccaps );
}
}
add_action( 'wp_insert_post', 'inherit_parent_protections', 10, 3 );
/* ADD CLIENTS AS ADMIN MENU ITEM WITHIN USERS MENU */
function admin_clients_menu() {
add_users_page(__( 'Clients' ), __( 'Clients' ), 'read', 'users.php?role=s2member_level2'); // must match clients user role
}
add_action( 'admin_menu', 'admin_clients_menu' );
A variation on this approach, which will probably be preferable for those with a large number of clients, is to use a custom post type for client pages instead of a regular page. This allows clients' pages to be kept separate from regular pages.
If you'd prefer to do this, you first need to make three small changes to the above code. The first requires that you replace this line:
'post_type' => 'page',
with this:
'post_type' => 's2-client-page',
The second change is to replace this line:
if ( 'page' != $post_type ) {
with this:
if ( 's2-client-page' != $post_type ) {
The third is to replace this line:
$page = get_page_by_path( $username, OBJECT );
with this:
$page = get_page_by_path( $username, OBJECT, 's2-client-page' );
Then you can just add the following code after all the code above:
/* CREATE CLIENT PAGE CUSTOM POST TYPE */
function create_portal_page_custom_post_type() {
$labels = array(
'name' => _x( 'Client Pages', 'client pages', 's2-client-portal' ),
'singular_name' => _x( 'Client Page', 'client page', 's2-client-portal' ),
'menu_name' => _x( 'Client Pages', 's2-client-portal' ),
'name_admin_bar' => _x( 'Client Page', 'add new on admin bar', 's2-client-portal' ),
'add_new' => _x( 'Add New', 's2-client-portal' ),
'add_new_item' => __( 'Add New Client Page', 's2-client-portal' ),
'new_item' => __( 'New Client Page', 's2-client-portal' ),
'edit_item' => __( 'Edit Client Page', 's2-client-portal' ),
'update_item' => __( 'Update Client Page', 's2-client-portal' ),
'view_item' => __( 'View Client Page', 's2-client-portal' ),
'all_items' => __( 'All Client Pages', 's2-client-portal' ),
'search_items' => __( 'Search Client Pages', 's2-client-portal' ),
'parent_item_colon' => __( 'Parent Client Page:', 's2-client-portal' ),
'not_found' => __( 'Not Found', 's2-client-portal' ),
'not_found_in_trash' => __( 'Not found in Trash', 's2-client-portal' ),
);
$args = array(
'label' => __( 'Client Pages', 's2-client-portal' ),
'description' => __( 'Client Page description', 's2-client-portal' ),
'labels' => $labels,
// Features the Client Page CPT supports in post editor
'supports' => array( 'title', 'editor', 'author', 'thumbnail', 'comments', 'custom-fields', 'page-attributes' ), // change as you like
'rewrite' => array( 'slug' => 'clients', 'with_front' => false ),
'hierarchical' => true,
'public' => true,
'show_ui' => true,
'show_in_menu' => true,
'menu_position' => 5, // change as you like
'show_in_nav_menus' => true,
'show_in_admin_bar' => true,
'can_export' => true,
'has_archive' => false,
'exclude_from_search' => false,
'query_var' => true,
'menu_icon' => 'dashicons-businessman',
'publicly_queryable' => true,
'capability_type' => 'page',
);
// Register the Client Page Custom Post Type
register_post_type( 's2-client-page', $args );
}
add_action( 'init', 'create_portal_page_custom_post_type', 0 );
Note: one thing you should do if you take this approach is to check your settings in s2Member -> Restriction Options -> Post Access Restrictions
Although this new custom post type for Client Pages will act like a page (so that it can have parents and children), WordPress still considers it a post. So if you have, for example, set all posts to be protected at a certain level, that protection will also be applied to the Client Pages you create. For many site admins, this might be a good thing, but you need to check to avoid surprises later on.
Having tested this further, I can say that it's easy to augment the above code to create subfolders to hold different types of files. Providing a client with the ability to download such files is then just a matter of installing the s2Member Secure File Browser plugin and including its shortcode in the code above.
For menus, you can provide a client with a list of accessible pages by installing an appropriate Child Pages plugin (whose shortcode you could also include in the code above to automate everything). This prevents the creation of an enormous menu that's out of control, and it can be done within a widget if preferred.
So I think, in fact, that that covers everything we have discussed on this thread. Unless someone has some additional comments, I would be content to see this thread closed.
(Amazing what a pair of brackets can do, and I've no idea what role they perform, so I'll need to look up that syntax.)
@KTS915 Woohoo! I love the work you're doing here. I'm reviewing it now, but just wanted to stop and answer the []
mystery. Those are array brackets. So instead of it being a string, the brackets make it an array. That's what the meta box does. It takes a comma-delimited list of CCAPs and converts them into an array. So that's why the array syntax was necessary there.
the
[]
mystery.
Note also that's known as a short array syntax and was added as a new feature in PHP 5.4: http://php.net/manual/en/migration54.new-features.php (So instead of array('key'=>'value')
you can just do ['key'=>'value']
.)
@KTS915 👍 💯 On your work here! I love the approach. Thank you for explaining how it all works too, and for documenting this for us and for others. I imagine this approach is likely to end up in a future KBA at the very least. So again, thank you!
One thing that caught my eye in the technique above, is the way in which CCAPs are inherited from the parent, and that it's assumed there will always be just a single ancestor.
In the outline posted by @KTS915, CCAPs are inherited as they are saved, and so this parent/child relationship is not dynamic, which could make it somewhat difficult to update if CCAP requirements are altered in the future; i.e., those changes would then need to trickle-down to children. The way @KTS915 set this up makes it work just fine for this use case though.
So I can't say I'm against the approach taken. It works just fine!
However, perhaps a cleaner way of doing this would be to use the CPT (Custom Post Type) that he mentioned, and then have Posts of that type automatically inherit permissions from all of their ancestors, infinitely deep; i.e., inherit dynamically, instead of by copying/juggling them in the database metadata.
If anyone wants to try this, you could start by reviewing this file, which handles the security checks associated with single posts (of any type, except pages). For pages, see this file.
You'll notice there is already a special consideration in this file for posts, for bbPress forums. Protecting a bbPress forum post type will automatically protect any topics/replies in that forum. The same sort of thing could be applied to any post type, and that would make it more dynamic. Also, there is a function in WP core that might be helpful too. See: get_post_ancestors()
.
@jaswsinc: Thanks very much for your comments! I am pleased that you haven't found any major problems, and would be happy to write this up as a KBA.
Thanks too (and to @raamdev) for the explanation about the use of brackets in that bit of code.
You have also read my mind about the need to consider further what happens if a ccap is changed. I really intended that the ccaps would rarely be changed, but you are right that there needs nevertheless to be a way to do so without causing new problems.
I don't think the issue is so much that my code assume a single ancestor. Each page inherits its protections from its parent, so you can have an endless chain of "generations," where each new one inherits from the previous one. In fact, that's precisely how I intend to use it. The problem is simply what happens to the other generations when one generation's ccap protection changes.
It could be the first generation ancestor whose ccap changes, or it could be a change made in any other generation. For the use-cases for which I intend to use this code, simply having later generations inherit all the ccaps of the previous generations would be highly problematic because I think that would make it too easy to cause one client accidentally to be granted access to another client's pages without the user (or his/her organization) realizing his/her mistake.
So I'm thinking that a change to the ccap protection of the first generation ancestor should trigger a change to the ccap protection of all subsequent generations, but that a ccap change in any other generation should not do so. Instead, that should generate some sort of warning that there is a mismatch between the ccaps and one of the parent-child relationships. At the moment, I think that this would provide the best way of trying to ensure that such changes are not made by mistake and, when they are, making them noticeable and easy to correct.
Something I've just realized is that the fact that the inherit_parent_protections
function fires via the wp_insert_post
significantly mitigates the risk of mistakenly associating a page with the wrong ccap protection. This is because that hook fires on updates as well as on new posts, so that it's triggered when an attempt is made to change the ccaps.
All we then need to do is change this line:
add_post_meta( $post_id, 's2member_ccaps_req', $ccaps );
to this:
update_post_meta( $post_id, 's2member_ccaps_req', $ccaps );
This ensures that, if the page's parent has not changed, the ccap will be reset to the parent's ccap, as it should be, even if the ccap has just been manually changed. In other words, this method ensures that the only way to change a child page's ccap protection is by changing its parent.
And, just to be clear, although the new code talks of updating the post_meta
instead of adding it, this still works even if a new page is being created, because it then reverts to adding the post_meta
anyway.
So now I just need to work out the function for changing the ccap protection for all the sub-generations when that of the first generation (which, by definition, has no parent of its own) is changed.
I've finally worked out how to create a function that auto-updates the ccaps protection of all descendants to match that of the first ancestor if the latter's ccaps protection is changed:
/* UPDATE CCAPS FOR ALL CHILDREN WHEN CCAPS OF FIRST ANCESTOR CHANGE */
function update_s2_client_ccaps_meta( $meta_id, $post_id, $meta_key, $meta_value ) {
$post_type = get_post_type( $post_id );
if ( 's2-client-page' != $post_type ) {
return; // only apply to Client Pages
}
$ancestors = get_post_ancestors( $post_id );
if ( empty( $ancestors ) && 's2member_ccaps_req' == $meta_key ) { // check that page has no parent and that its ccaps protection has been updated
$pages = get_pages( array( 'post_type' => $post_type ) );
$children =&get_page_children( $post_id, $pages );
foreach ( $children as $child ) {
update_post_meta( $child->ID, 's2member_ccaps_req', $meta_value );
}
}
}
add_action( 'updated_post_meta', 'update_s2_client_ccaps_meta', 10, 4 );
This means that if the first ancestor's ccaps protection (i.e. the ccaps protection for the original client portal page) is changed so as to associate it with a different client, all its sub-pages (no matter how many generations there might be) will now be automatically given the same ccaps protection too and therefore only be accessible by that new client.
Thinking about this further, it raises the question of whether the update_s2_client_ccaps_meta
function should be restricted in its operation only to situations when an updated client page has no ancestors.
Would it simply be better to leave out the empty( $ancestors ) &&
check and just have the ccaps protection of all sub-pages updated whenever an ancestor's protection is changed?
I don't think the issue is so much that my code assume a single ancestor. Each page inherits its protections from its parent, so you can have an endless chain of "generations," where each new one inherits from the previous one. In fact, that's precisely how I intend to use it. The problem is simply what happens to the other generations when one generation's ccap protection changes.
Right, I see. Thanks for pointing that out.
Would it simply be better to leave out the empty( $ancestors ) && check and just have the ccaps protection of all sub-pages updated whenever an ancestor's protection is changed?
I think so, yes. Actually, here's how I imagine it working best.
Each s2_client_page
can be associated with CCAPs, as you have now.
If children are created, they automatically inherit all CCAP restrictions from ancestors, and this shouldn't require any DB writes. It should just happen naturally; i.e., the security routines should consider this. In other words, if an s2_client_page
has ancestors, it should automatically inherit all CCAP restrictions from those ancestors.
Then, each child can also add new CCAP requirements of it's own; i.e., to add to those it already inherits from ancestors that it has.
With that structure, then you can support an unlimited number of parent/child relationships with very little code. For example, you could have a child that is 12 levels deep, and it would inherit (automatically) all CCAP restrictions from each ancestor before it. Moving up the tree, a child at level 5 could introduce a new CCAP, and that would impact all levels beneath it.
@jaswsinc: Thanks for these comments.
Having thought about it further, I agree that it would be better to leave out the empty( $ancestors ) &&
check in my last piece of code.
I also agree that your suggestions as to how the whole process should work are technically far superior to what I have coded!
Nevertheless, they cause me two problems.
The first is that such coding requires a level of skill with PHP well above what I possess! So you -- or someone else with that level of skill -- would need to work out the necessary code modifications. It's simply beyond my expertise.
Second, though, I don't think your model of how this should work corresponds to the use to which I am planning to put this. I envision it being used by attorneys, for whom the possibility that additional ccaps might be added part-way down a "tree" of client pages would be quite unacceptable. Attorneys are under a strict obligation of client confidentiality, and permitting the addition of new ccaps would mean opening up access to client information to people other than the client. That would be a disciplinary offense. That's why my focus has been so linear: it deliberately associates each client page with just one client.
I am guessing that you have very different businesses or organizations in mind -- probably in a more co-operative environment -- for when a client portal may be useful, and I don't doubt at all that your approach would be better suited for those circumstances. I just don't think it's appropriate for the use-case I have in mind.
A customer writes...
This is possible, but it is non-trivial. The closest (friendly) way to accomplish this is by using a Special Redirection URL in your Login Welcome Page configuration. One which includes the
%%user_nicename%%
Replacement Code mentioned in this area.See: Dashboard → s2Member → General Options → Login Welcome Page
Feature Request
Add a new endpoint to WordPress when s2Member is installed, which only the current user can gain access to. For instance:
http://example.com/u/johndoe22