PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.4.5
Fluent Support – Helpdesk & Customer Support Ticket System v2.4.5
2.4.5 2.4.0 2.3.2 2.3.1 2.3.0 2.2.1 2.2.0 trunk 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.10.5 1.4.0 1.4.1 1.4.2 1.4.5 1.4.6 1.4.7 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 All 69 releases
← All changes | app/Services/ProfileInfoService.php +110 -101 2.4.0 → 2.4.5 View file →
@@ -7,25 +7,104 @@
7 7 use FluentSupport\App\Models\Ticket;
8 8
9 9 class ProfileInfoService
10 10 {
11 - public static function getProfileExtraWidgets( $customer )
11 + /**
12 + * @param object|array $customer
13 + * @param array $context See widgetContext().
14 + * @return array
15 + */
16 + public static function getProfileExtraWidgets($customer, $context = [])
12 17 {
13 18 $widgets = [];
14 19 /*
15 20 * Filter customer profile widgets
16 21 *
22 + * Integrations should build only what $context asks for:
23 + * - format 'html' (agent UI): stats / sections (preferred) or body_html, no 'mcp' key.
24 + * - format 'mcp' (AI agents via MCP): an 'mcp' array of plain data, no body_html.
25 + * body_html still works; it is converted to Markdown.
26 + * - keys: when set, only these widget keys are wanted. Skip the queries for any other.
27 + * Use ProfileInfoService::wantsWidget() and isMcpWidgetRequest() to check.
28 + *
29 + * Structured widgets are drawn by the sidebar as badges, tiles, rows and lists,
30 + * all as plain text. Every key is optional. Dates are ISO 8601 and shown
31 + * relative ("9 months ago"); URLs must be absolute http(s):
32 + *
33 + * $widgets['my_shop'] = [
34 + * 'title' => 'My Shop',
35 + * 'icon' => 'ShoppingBag', // see WIDGET_ICONS in _StructuredWidget.vue
36 + * 'badges' => [['text' => 'Subscribed', 'type' => 'success']], // success|warning|danger|info|primary
37 + * 'stats' => [ // tiles, two per row
38 + * ['label' => 'Lifetime Value', 'value' => '$748.00', 'url' => '...'],
39 + * ['label' => 'Last Order', 'value' => '2026-01-09T08:30:00+00:00', 'type' => 'date'],
40 + * ],
41 + * 'facts' => [ // compact label / value rows
42 + * ['label' => 'Location', 'value' => 'Berlin, Germany'],
43 + * ],
44 + * 'sections' => [
45 + * ['label' => 'Recent Purchases', 'items' => [
46 + * ['title' => 'Pro Plan', 'subtitle' => '5 Sites', 'detail' => '3 renewals', 'url' => '...',
47 + * 'meta' => '2026-01-09T08:30:00+00:00', 'meta_type' => 'date',
48 + * 'badge' => ['text' => 'active', 'type' => 'success']],
49 + * ]],
50 + * ['label' => 'Tags', 'tags' => ['VIP', 'Agency']],
51 + * ],
52 + * 'links' => [['label' => 'View in My Shop', 'url' => '...']],
53 + * ];
54 + *
17 55 * @since v1.0.0
56 + * @since v2.4.1 $context, structured widgets
18 57 * @param array $widgets
19 58 * @param object|array $customer
59 + * @param array $context ['format' => 'html'|'mcp', 'keys' => string[]|null]
20 60 *
21 - * @return void
61 + * @return array
22 62 */
23 - $widgets = apply_filters('fluent_support/customer_extra_widgets', $widgets, $customer);
63 + $widgets = apply_filters('fluent_support/customer_extra_widgets', $widgets, $customer, self::widgetContext($context));
24 64 return $widgets;
25 65 }
26 66
27 - // This method is linked with 'profile_update' action & it will trigger when user update profile
67 + /**
68 + * Normalized context for fluent_support/customer_extra_widgets.
69 + *
70 + * @param array $context
71 + * @return array ['format' => 'html'|'mcp', 'keys' => string[]|null]
72 + */
73 + public static function widgetContext($context = [])
74 + {
75 + $format = isset($context['format']) && $context['format'] === 'mcp' ? 'mcp' : 'html';
76 + $keys = isset($context['keys']) && is_array($context['keys'])
77 + ? array_values(array_map('strval', $context['keys']))
78 + : null;
79 +
80 + return ['format' => $format, 'keys' => $keys];
81 + }
82 +
83 + /**
84 + * Whether the caller wants this widget. Call it before running the widget's queries.
85 + *
86 + * @param array $context The filter's third argument.
87 + * @param string $key Widget key, e.g. 'woo_purchases'.
88 + * @return bool
89 + */
90 + public static function wantsWidget($context, $key)
91 + {
92 + return !is_array($context) || empty($context['keys']) || in_array((string) $key, $context['keys'], true);
93 + }
94 +
95 + /**
96 + * Whether the caller wants structured 'mcp' data instead of body_html.
97 + *
98 + * @param array $context The filter's third argument.
99 + * @return bool
100 + */
101 + public static function isMcpWidgetRequest($context)
102 + {
103 + return is_array($context) && isset($context['format']) && $context['format'] === 'mcp';
104 + }
105 +
106 + // Hooked to 'profile_update'.
28 107 public function onWPProfileUpdate($userId, $userOldData, $userUpdatedData)
29 108 {
30 109 if (!$userId || !is_array($userUpdatedData)) {
31 110 return false;
@@ -30,11 +109,9 @@
30 109 if (!$userId || !is_array($userUpdatedData)) {
31 110 return false;
32 111 }
33 112
34 - // Matching on user_id only is what keeps this safe: a WordPress account
35 - // can never reach a customer row it is not already linked to, so this
36 - // can only ever rewrite the requester's own record.
113 + // Match on user_id only, so an account can only rewrite customer rows already linked to it.
37 114 $customers = Customer::where('user_id', $userId)->get();
38 115
39 116 if (!$customers || count($customers) === 0) {
40 117 return false;
@@ -45,46 +122,25 @@
45 122 if (array_diff_key(array_flip($keys), $userUpdatedData)) {
46 123 return false;
47 124 }
48 125
49 - // wp_insert_user() takes slashed data, and profile_update hands that same
50 - // array straight on, so a name like O'Brien arrives as O\'Brien. Written
51 - // through unchanged it reaches the customer record with a literal
52 - // backslash, which is what the customer then sees in the portal and on
53 - // every notification addressed to them.
126 + // profile_update passes wp_insert_user()'s slashed data (O\'Brien).
54 127 $userUpdatedData = wp_unslash($userUpdatedData);
55 128
56 129 $email = $userUpdatedData['user_email'];
57 130
58 - // Any plugin calling wp_update_user() for any reason fires this hook, so
59 - // most of the time the account address has not moved at all. Reading the
60 - // pending-change meta and running a capability check on every one of
61 - // those is work for nothing.
62 - //
63 - // It also keeps the administrator path honest. Without this, an
64 - // administrator editing somebody's first name would move the support
65 - // address onto whatever unverified address the account happened to be
66 - // carrying, because the check below only compares the customer record to
67 - // the account. Authority is granted for the change the actor actually
68 - // made, not for a divergence somebody else created earlier.
69 - // Unslashed on both sides. wp_insert_user() slashes the previous address
70 - // on purpose -- "Slash current user email to compare it later with
71 - // slashed new user email" -- so comparing it against the unslashed new
72 - // one reads an address like o'[email protected] as changed on every
73 - // update, and an administrator editing only a name would move the
74 - // support address onto it.
131 + // Only check authority when this update actually changed the account email.
132 + // Otherwise an admin editing a name would carry over an unverified address
133 + // the account already had. Core slashes the old email, so unslash it too.
75 134 $previousAccountEmail = is_object($userOldData) && isset($userOldData->user_email)
76 135 ? wp_unslash($userOldData->user_email)
77 136 : '';
78 137
79 - // An unknown previous address falls through to the full check rather
80 - // than silently skipping it.
138 + // Unknown previous address: run the full check.
81 139 $accountEmailMoved = !$previousAccountEmail
82 140 || !self::isSameEmail($previousAccountEmail, $email);
83 141
84 - // Only a proven change may move the address that support notifications
85 - // and signed ticket links are delivered to. Names are cosmetic and are
86 - // always synced.
142 + // Only a proven change may move the email; names always sync.
87 143 $changeReason = $accountEmailMoved ? self::provenEmailChangeReason($userId, $email) : '';
88 144 $emailIsProven = (bool) $changeReason;
89 145
90 146 foreach ($customers as $customer) {
@@ -94,12 +150,10 @@
94 150 ];
95 151
96 152 $previousEmail = $customer->email;
97 153
98 - // Never move this row onto an address another customer already
99 - // holds. Duplicate emails make every later email-based match
100 - // ambiguous, and mail piping would then thread onto whichever row
101 - // happens to have the lower id.
154 + // Don't take an address another customer holds. Duplicates make
155 + // email matching (e.g. mail piping) ambiguous.
102 156 $emailMoved = $emailIsProven
103 157 && !self::isSameEmail($previousEmail, $email)
104 158 && !self::isEmailHeldByAnotherCustomer($email, $customer->id);
105 159
@@ -118,16 +172,11 @@
118 172 return true;
119 173 }
120 174
121 175 /**
122 - * How the email change being applied right now was authorised, or an empty
123 - * string when it was not.
176 + * How the current email change was authorised: the owner confirmed it through
177 + * WordPress's link, or someone who can edit the account made it. '' otherwise.
124 178 *
125 - * Two paths count. The account holder can prove the new address by opening
126 - * WordPress's confirmation link, and somebody with authority over the
127 - * account can change it on their behalf. Everything else, which is most
128 - * things, leaves the customer's contact address where it is.
129 - *
130 179 * @param int $userId
131 180 * @param string $newEmail
132 181 * @return string 'verified', 'administrator', or ''
133 182 */
@@ -144,34 +193,14 @@
144 193 return '';
145 194 }
146 195
147 196 /**
148 - * Whether an administrator is changing this account's address on its
149 - * owner's behalf.
197 + * Whether someone else who can edit this WordPress account is changing its email.
198 + * Editing your own account never counts.
150 199 *
151 - * Authority over the WordPress account is the test. Someone who may edit
152 - * that account may also move the address Fluent Support writes to, on the
153 - * basis that the customer record's address is derived from the account in
154 - * the first place.
200 + * On WooCommerce, shop_manager has edit_user over 'customer' accounts, so it passes
201 + * this without any Fluent Support permission. Requiring fst_sensitive_data would close that.
155 202 *
156 - * Worth knowing what that admits on a WooCommerce store. WooCommerce grants
157 - * edit_users to shop_manager through a user_has_cap filter and then narrows
158 - * it, in wc_modify_map_meta_cap(), to accounts holding the 'customer' role,
159 - * which is the population Fluent Support serves. A shop manager therefore
160 - * passes this check for any customer, whether or not they hold a single
161 - * Fluent Support permission, and can redirect that customer's support
162 - * notifications. Narrowing this to holders of fst_sensitive_data, the
163 - * permission CustomerPolicy uses to gate editing a customer record, would
164 - * close that; it is a one-line change here.
165 - *
166 - * The address is authorised but still unproven, which is why this path
167 - * rotates ticket hashes exactly as the verified one does, so links already
168 - * delivered to the previous inbox stop working.
169 - *
170 - * Excluding the actor's own account matters: an administrator changing their
171 - * own address goes through the same unverified endpoints as anybody else and
172 - * gets no special treatment.
173 - *
174 203 * @param int $userId
175 204 * @return bool
176 205 */
177 206 public static function isAdminAuthorizedEmailChange($userId)
@@ -185,18 +214,13 @@
185 214 return current_user_can('edit_user', (int) $userId);
186 215 }
187 216
188 217 /**
189 - * Whether the email change being applied to this user right now went through
190 - * WordPress's own click-through confirmation.
218 + * Whether this email change came from WordPress's confirmation link.
191 219 *
192 - * wp-admin/user-edit.php calls wp_update_user() and only deletes the
193 - * _new_email meta afterwards, so the pending record is still readable while
194 - * profile_update fires. The record on its own proves nothing: submitting the
195 - * profile form writes it without any click, so an unverified change made
196 - * through REST could arrive with a matching address already sitting in meta.
197 - * Only the hash coming back in the request shows the link was opened from
198 - * the inbox the mail was delivered to.
220 + * _new_email is still readable while profile_update fires, but the profile form
221 + * writes it without any click. Only the matching hash in the request proves the
222 + * link was opened.
199 223 *
200 224 * @param int $userId
201 225 * @param string $newEmail
202 226 * @return bool
@@ -202,14 +226,10 @@
202 226 * @return bool
203 227 */
204 228 public static function isWpConfirmedEmailChange($userId, $newEmail)
205 229 {
206 - // Mirrors the condition core applies the confirmed change under.
207 - // wp-admin/user-edit.php runs the confirmation branch only when
208 - // IS_PROFILE_PAGE is truthy, and that constant is defined nowhere but
209 - // profile.php and user-edit.php, where it means "the user is editing
210 - // their own account". Requiring it here means a replayed hash cannot be
211 - // honoured from REST, WP-CLI, or an administrator editing somebody else.
230 + // Core only applies a confirmed change on the user's own profile page
231 + // (IS_PROFILE_PAGE). This stops a replayed hash via REST, WP-CLI or another admin.
212 232 if (!defined('IS_PROFILE_PAGE') || !IS_PROFILE_PAGE) {
213 233 return false;
214 234 }
215 235
@@ -231,16 +251,11 @@
231 251 return $submitted && hash_equals($pending['hash'], $submitted);
232 252 }
233 253
234 254 /**
235 - * Apply the consequences of a customer's contact address moving to one that
236 - * has been proven.
255 + * Side effects of a proven email change. Shared by every path that can move
256 + * the address, including EmailClaimService.
237 257 *
238 - * Shared by every path that is allowed to move an address -- WordPress's own
239 - * confirmation, an administrator with rights over the account, and the
240 - * portal claim flow in EmailClaimService -- so a proven move has exactly one
241 - * set of consequences however it was proven.
242 - *
243 258 * @param \FluentSupport\App\Models\Customer $customer
244 259 * @param string $previousEmail
245 260 * @param string $reason 'verified', 'administrator', 'claimed' or 'agent'
246 261 * @return void
@@ -246,11 +261,9 @@
246 261 * @return void
247 262 */
248 263 public static function onProvenEmailChange($customer, $previousEmail, $reason = 'verified')
249 264 {
250 - // Signed ticket links already delivered to the previous inbox keep
251 - // authorizing read, reply, close and reopen on these tickets until the
252 - // hash they carry stops matching.
265 + // Rotate hashes so signed links sent to the old inbox stop working.
253 266 $tickets = Ticket::where('customer_id', $customer->id)->get();
254 267
255 268 foreach ($tickets as $ticket) {
256 269 $ticket->hash = bin2hex(random_bytes(16));
@@ -318,16 +331,12 @@
318 331 return strtolower(trim((string) $left)) === strtolower(trim((string) $right));
319 332 }
320 333
321 334 /**
322 - * Adopt customer rows that already carry this address but are not linked to
323 - * a WordPress account yet.
335 + * Link unclaimed customer rows with this email to the new account.
324 336 *
325 - * Registration is one of the few moments where an address may be treated as
326 - * belonging to the account: WordPress refuses to register an address that
327 - * another user already holds, and the credentials it sends go to that inbox.
328 - * A later email change carries no such proof, which is why binding is done
329 - * here rather than on every profile update.
337 + * Safe at registration because WordPress sends the credentials to that inbox.
338 + * A later email change has no such proof, so we don't link then.
330 339 *
331 340 * @param int $userId
332 341 * @return void
333 342 */