An admin can open a member's notification preferences but cannot save them (FluentCommunity 2.10.01)
Written 2026-09-18 against 2.10.01, remeasured the same evening against
2.11.0 on the staging site: all four guards and both routes are
unchanged, only the line numbers moved. Corrected after looking at the actual
screen: the save button is not rendered at all for a foreign profile β the
earlier draft claimed it was there and the click failed. That was inferred
from the controller instead of looked at, and it was wrong.
Earlier reports:upstream/2026-09-03-notification-row-and-license-message-en.md
(the emptyco_commentrow, shipped fixed as "Empty Notification Preference
Row When Push Is Unavailable"),upstream/2026-08-19-string-issues-en.md,
upstream/2026-08-26-profile-bio-placeholder-en.md.
Not a translation point this time. I run a FluentCommunity site and maintain the
German catalogues, and this is a permission asymmetry that leaves an admin with
an editable form and no way to commit it.
What happens
As a WordPress super admin, I open another member's notification preferences in
the portal. The page renders, the checkboxes carry that member's real values,
and I can change them. There is no save button. The edits go nowhere.
Why
Three places decide this, and they do not agree. Reading admits admins, editing
admits admins, saving does not β and saving is blocked twice over, once in the
UI and once in the controller.
The portal renders the save block only for the profile's owner. In
assets/app.js, the whole block hangs off isOwn, while the same component
loads the preferences for a super admin on purpose:
// the save block, rendered only when isOwn
i.isOwn ? (β¦, { onClick: i.saveSettings, β¦ }, β¦ "Save Changes" β¦) : _("", !0)
// isOwn compares the profile in the URL with the logged-in user
isOwn() { return this.username === this.auth.username }
// but loading explicitly admits super admins
mounted() { this.isOwn || this.hasPermission("super_admin") ? this.getPrefs() : β¦ }
Nothing disables the checkboxes in between, so the form stays interactive for an
admin. It just has no way to submit.
And behind that, read and write hang off two different guards, only one of
which knows about admins:
// app/Http/Controllers/ProfileController.php:720
public function getNotificationPreferance(Request $request, $userName)
{
...
$xProfile = $this->verifyAndGetProfile($userName);
// app/Http/Controllers/ProfileController.php:904
public function saveNotificationPreferance(Request $request, $userName)
{
$xProfile = $this->verifyAndGetOwnProfile($userName);
// app/Http/Controllers/ProfileController.php:1000
private function verifyAndGetProfile($userName)
{
$xProfile = XProfile::where('username', $userName)->firstOrFail();
$currentUserId = get_current_user_id();
if ($xProfile->user_id != $currentUserId && !Helper::isSuperAdmin($currentUserId)) {
throw new \Exception('You are not allowed to update this profile');
}
return $xProfile;
}
// app/Http/Controllers/ProfileController.php:1012
private function verifyAndGetOwnProfile($userName)
{
$xProfile = XProfile::where('username', $userName)->firstOrFail();
if (!get_current_user_id() || $xProfile->user_id != get_current_user_id()) {
throw new HttpException(403, esc_html__('You are not allowed to access these notification preferences.', 'fluent-community'));
}
return $xProfile;
}
Both endpoints sit on the same username-scoped route pair:
// app/Http/Routes/api.php:98-99
$router->get('/{username}/notification-preferences', 'ProfileController@getNotificationPreferance')->alphaNumDash('username');
$router->post('/{username}/notification-preferences', 'ProfileController@saveNotificationPreferance')->alphaNumDash('username');
So GET admits a super admin on any profile, POST admits nobody but the owner.
Worth noting: an admin never sees the 403 message, "You are not allowed to
access these notification preferences", because the UI hides the button before
any request goes out. The string is reachable through the API only. What the
admin sees instead is a form that silently keeps his edits and then drops them
on the next navigation.
Whichever way you decide this, the three should agree. Either the write path and
the save block admit admins, or the read path stops handing an admin an editable
form. What it should not do is all three at once: load the data, let him type,
and then quietly have nowhere to put it.
This looks like collateral from the hardening pass
Editing another member's preferences used to be a supported path. Your own
changelog fixed a bug in it:
= 2.7.0 (Date: Jul 8, 2026) =
- Fixed: Notification Settings Space Scope on Other Profiles
and in 2.3.0 there was a single guard, which let community moderators through:
// 2.3.0, app/Http/Controllers/ProfileController.php
if ($xProfile->user_id != get_current_user_id() && (!$currentUser || !$currentUser->isCommunityModerator())) {
throw new \Exception('You are not allowed to update this profile');
}
I cannot pin the exact release that split the guards, since I only have 2.3.0
and the current sources to compare. Judging by the changelog, 2.9.0's "Security: Access
Control, Data Scoping and Input Sanitization Hardening" is the likely spot. If
that is right, the write path was tightened to owner-only and the read path kept
its admin branch, and the mismatch is an oversight rather than a decision.
Why an admin wants this
My members are not all comfortable in a portal. They phone or write to me: "I
get too much email from the community, please turn it off." Today I have three
options, and all three are bad. I can talk them through five checkboxes over the
phone, or ask them for their password, or write to the database behind your back.
The same case shows up for anyone running a community for a non-technical
audience: onboarding a new member with sensible defaults, quieting a member who
is about to unsubscribe from everything, or fixing a member who accidentally
turned off the digest and now believes the community is dead.
Suggestion
Two small changes, and they belong together β fixing only the controller would
leave the button hidden, fixing only the button would turn a silent loss into a
403.
In the portal, render the save block on the same condition that already
decides whether the preferences get loaded at all:
// instead of isOwn
isOwn || hasPermission('super_admin')
In the controller, give the write path the same admin branch the read path
already has:
private function verifyAndGetOwnProfile($userName)
{
$xProfile = XProfile::where('username', $userName)->firstOrFail();
$currentUserId = get_current_user_id();
if (!$currentUserId || ($xProfile->user_id != $currentUserId && !Helper::isSuperAdmin($currentUserId))) {
throw new HttpException(403, esc_html__('You are not allowed to access these notification preferences.', 'fluent-community'));
}
return $xProfile;
}
If you would rather keep owner-only as the default, a filter would be enough for
site owners like me:
$canManage = apply_filters(
'fluent_community/can_manage_member_notification_prefs',
Helper::isSuperAdmin($currentUserId),
$xProfile->user_id,
$currentUserId
);
Either way, please keep NotificationPref::updateUserPrefs() as the public
entry point it is today. That is the door I am using in the meantime: a small
admin-side plugin of my own reads with getUserPrefs(), writes with
updateUserPrefs(), and never touches fcom_notification_prefs directly. It
works, and it is a detour around your own API, which is why I would rather have
the route.
One note for whoever picks this up: since 2.9.0 the preferences live in their
own table with the channel as a column, and updateUserPrefs() also clears the
per-user cache (user_notification_pref_<id>). Any admin-side path that writes
rows directly would leave that cache stale, so the service really is the only
sane entry point.
Happy to send a PR for the guard, the button condition or the filter, whichever
you prefer.
Noted
The work has been done in dev, thank you for the detailed post.