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 +327 -48 1.10.5 → 2.4.5 View file →
@@ -1,105 +1,384 @@
1 1 <?php
2 2
3 3 namespace FluentSupport\App\Services;
4 4
5 +use FluentSupport\App\Models\Activity;
5 6 use FluentSupport\App\Models\Customer;
6 -use FluentSupport\App\Models\Product;
7 -use FluentSupport\App\Modules\PermissionManager;
8 -use FluentSupport\App\Services\EmailNotification\Settings;
7 +use FluentSupport\App\Models\Ticket;
9 8
10 9 class ProfileInfoService
11 10 {
12 - 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 = [])
13 17 {
14 18 $widgets = [];
15 19 /*
16 20 * Filter customer profile widgets
17 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 + *
18 55 * @since v1.0.0
56 + * @since v2.4.1 $context, structured widgets
19 57 * @param array $widgets
20 58 * @param object|array $customer
59 + * @param array $context ['format' => 'html'|'mcp', 'keys' => string[]|null]
21 60 *
22 - * @return void
61 + * @return array
23 62 */
24 - $widgets = apply_filters('fluent_support/customer_extra_widgets', $widgets, $customer);
63 + $widgets = apply_filters('fluent_support/customer_extra_widgets', $widgets, $customer, self::widgetContext($context));
25 64 return $widgets;
26 65 }
27 66
28 - // This method is linked with 'profile_update' action & it will trigger when user update profile
29 - public function onWPProfileUpdate( $userId, $userOldData, $userUpdatedData )
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 = [])
30 74 {
31 - if ( !$userId ) {
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'.
107 + public function onWPProfileUpdate($userId, $userOldData, $userUpdatedData)
108 + {
109 + if (!$userId || !is_array($userUpdatedData)) {
32 110 return false;
33 111 }
34 112
35 - $customer = Customer::where( 'user_id', $userId );
113 + // Match on user_id only, so an account can only rewrite customer rows already linked to it.
114 + $customers = Customer::where('user_id', $userId)->get();
36 115
37 - if ( $customer->count() == 0 ) {
116 + if (!$customers || count($customers) === 0) {
38 117 return false;
39 118 }
40 119
41 120 $keys = ['first_name', 'last_name', 'user_email'];
42 - $data = [];
43 121
44 - if( !array_diff_key( array_flip($keys), $userUpdatedData ) ) {
122 + if (array_diff_key(array_flip($keys), $userUpdatedData)) {
123 + return false;
124 + }
125 +
126 + // profile_update passes wp_insert_user()'s slashed data (O\'Brien).
127 + $userUpdatedData = wp_unslash($userUpdatedData);
128 +
129 + $email = $userUpdatedData['user_email'];
130 +
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.
134 + $previousAccountEmail = is_object($userOldData) && isset($userOldData->user_email)
135 + ? wp_unslash($userOldData->user_email)
136 + : '';
137 +
138 + // Unknown previous address: run the full check.
139 + $accountEmailMoved = !$previousAccountEmail
140 + || !self::isSameEmail($previousAccountEmail, $email);
141 +
142 + // Only a proven change may move the email; names always sync.
143 + $changeReason = $accountEmailMoved ? self::provenEmailChangeReason($userId, $email) : '';
144 + $emailIsProven = (bool) $changeReason;
145 +
146 + foreach ($customers as $customer) {
45 147 $data = [
46 148 'first_name' => $userUpdatedData['first_name'],
47 - 'last_name' => $userUpdatedData['last_name'],
48 - 'email' => $userUpdatedData['user_email'],
149 + 'last_name' => $userUpdatedData['last_name'],
49 150 ];
50 - };
51 151
52 - if ( count($data) > 0 ) {
53 - $customer->update($data);
152 + $previousEmail = $customer->email;
153 +
154 + // Don't take an address another customer holds. Duplicates make
155 + // email matching (e.g. mail piping) ambiguous.
156 + $emailMoved = $emailIsProven
157 + && !self::isSameEmail($previousEmail, $email)
158 + && !self::isEmailHeldByAnotherCustomer($email, $customer->id);
159 +
160 + if ($emailMoved) {
161 + $data['email'] = $email;
162 + }
163 +
164 + $customer->fill($data);
165 + $customer->save();
166 +
167 + if ($emailMoved) {
168 + self::onProvenEmailChange($customer, $previousEmail, $changeReason);
169 + }
54 170 }
55 171
172 + return true;
56 173 }
57 174
175 + /**
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.
178 + *
179 + * @param int $userId
180 + * @param string $newEmail
181 + * @return string 'verified', 'administrator', or ''
182 + */
183 + public static function provenEmailChangeReason($userId, $newEmail)
184 + {
185 + if (self::isWpConfirmedEmailChange($userId, $newEmail)) {
186 + return 'verified';
187 + }
58 188
189 + if (self::isAdminAuthorizedEmailChange($userId)) {
190 + return 'administrator';
191 + }
192 +
193 + return '';
194 + }
195 +
59 196 /**
60 - * This `me` method will return the current user profile info
61 - * @param array $settings
62 - * @param arrau withPortalSettings
63 - * @return array $settings
197 + * Whether someone else who can edit this WordPress account is changing its email.
198 + * Editing your own account never counts.
199 + *
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.
202 + *
203 + * @param int $userId
204 + * @return bool
64 205 */
65 - public function me( $settings, $withPortalSettings )
206 + public static function isAdminAuthorizedEmailChange($userId)
66 207 {
67 - if ( $withPortalSettings ) {
68 - $this->portalSettings( $settings );
208 + $actorId = get_current_user_id();
209 +
210 + if (!$actorId || $actorId === (int) $userId) {
211 + return false;
69 212 }
70 213
71 - return $settings;
214 + return current_user_can('edit_user', (int) $userId);
72 215 }
73 216
74 217 /**
75 - * This `portalSettings` method is supporting the `me` method
76 - * @param array $settings
77 - * @return array $settings
218 + * Whether this email change came from WordPress's confirmation link.
219 + *
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.
223 + *
224 + * @param int $userId
225 + * @param string $newEmail
226 + * @return bool
78 227 */
79 - private function portalSettings ( $settings )
228 + public static function isWpConfirmedEmailChange($userId, $newEmail)
80 229 {
81 - $mimeHeadings = Helper::getAcceptedMimeHeadings();
82 - $businessSettings = (new Settings())->globalBusinessSettings();
83 - $maxFileSize = absint($businessSettings['max_file_size']);
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.
232 + if (!defined('IS_PROFILE_PAGE') || !IS_PROFILE_PAGE) {
233 + return false;
234 + }
84 235
85 - $portalSettings = [
86 - 'support_products' => Product::select(['id', 'title'])->get(),
87 - 'customer_ticket_priorities' => Helper::customerTicketPriorities(),
88 - 'has_file_upload' => !!Helper::ticketAcceptedFileMiles(),
89 - 'has_rich_text_editor' => true,
90 - 'max_file_size' => $maxFileSize,
91 - 'mime_headings' => $mimeHeadings
92 - ];
93 - /**
94 - * Filter customer portal settings
236 + $pending = get_user_meta($userId, '_new_email', true);
237 +
238 + if (!is_array($pending) || empty($pending['hash']) || empty($pending['newemail'])) {
239 + return false;
240 + }
241 +
242 + if (!self::isSameEmail($pending['newemail'], $newEmail)) {
243 + return false;
244 + }
245 +
246 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- this compares a secret delivered to the user's own inbox; it does not act on request state
247 + $submitted = isset($_GET['newuseremail'])
248 + ? sanitize_text_field(wp_unslash($_GET['newuseremail']))
249 + : '';
250 +
251 + return $submitted && hash_equals($pending['hash'], $submitted);
252 + }
253 +
254 + /**
255 + * Side effects of a proven email change. Shared by every path that can move
256 + * the address, including EmailClaimService.
257 + *
258 + * @param \FluentSupport\App\Models\Customer $customer
259 + * @param string $previousEmail
260 + * @param string $reason 'verified', 'administrator', 'claimed' or 'agent'
261 + * @return void
262 + */
263 + public static function onProvenEmailChange($customer, $previousEmail, $reason = 'verified')
264 + {
265 + // Rotate hashes so signed links sent to the old inbox stop working.
266 + $tickets = Ticket::where('customer_id', $customer->id)->get();
267 +
268 + foreach ($tickets as $ticket) {
269 + $ticket->hash = bin2hex(random_bytes(16));
270 + $ticket->save();
271 + }
272 +
273 + Activity::create([
274 + 'event_type' => 'fluent_support/customer_email_changed',
275 + 'person_id' => $customer->id,
276 + 'person_type' => 'customer',
277 + 'object_id' => $customer->id,
278 + 'object_type' => 'customer',
279 + 'description' => sprintf(
280 + self::emailChangeDescription($reason),
281 + $previousEmail,
282 + $customer->email
283 + )
284 + ]);
285 +
286 + /*
287 + * Fires after a customer's contact address moves to a newly proven one.
95 288 *
96 - * @since v1.0.0
97 - * @param array $portalSettings
289 + * @since v2.4.1
290 + * @param \FluentSupport\App\Models\Customer $customer
291 + * @param string $previousEmail
292 + * @param string $reason
98 293 */
99 - $portalSettings = apply_filters('fluent_support/customer_portal_vars', $portalSettings);
294 + do_action('fluent_support/customer_email_verified_change', $customer, $previousEmail, $reason);
295 + }
100 296
101 - $settings['portal_settings'] = $portalSettings;
297 + /**
298 + * Activity log wording for each way an address can be proven.
299 + *
300 + * @param string $reason
301 + * @return string A sprintf format taking the previous and new addresses
302 + */
303 + protected static function emailChangeDescription($reason)
304 + {
305 + if ($reason === 'administrator') {
306 + // translators: 1: previous email address, 2: new email address
307 + return __('Contact email changed from %1$s to %2$s by an administrator editing the WordPress account.', 'fluent-support');
308 + }
102 309
103 - return $settings;
310 + if ($reason === 'agent') {
311 + // translators: 1: previous email address, 2: new email address
312 + return __('Contact email changed from %1$s to %2$s by a support agent editing the customer.', 'fluent-support');
313 + }
314 +
315 + if ($reason === 'claimed') {
316 + // translators: 1: previous email address, 2: new email address
317 + return __('Contact email changed from %1$s to %2$s, confirmed by the account holder from the support portal.', 'fluent-support');
318 + }
319 +
320 + // translators: 1: previous email address, 2: new email address
321 + return __('Contact email changed from %1$s to %2$s, confirmed through WordPress email verification.', 'fluent-support');
322 + }
323 +
324 + /**
325 + * @param string $left
326 + * @param string $right
327 + * @return bool
328 + */
329 + protected static function isSameEmail($left, $right)
330 + {
331 + return strtolower(trim((string) $left)) === strtolower(trim((string) $right));
332 + }
333 +
334 + /**
335 + * Link unclaimed customer rows with this email to the new account.
336 + *
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.
339 + *
340 + * @param int $userId
341 + * @return void
342 + */
343 + public function onWPUserRegister($userId)
344 + {
345 + $user = $userId ? get_user_by('ID', $userId) : false;
346 +
347 + if (!$user || !$user->user_email) {
348 + return;
349 + }
350 +
351 + /*
352 + * Filter whether a newly registered WordPress account adopts unlinked
353 + * customer rows that already carry its email address. Sites that let
354 + * visitors register with a self-chosen password may want this off.
355 + *
356 + * @since v2.4.1
357 + * @param bool $shouldLink
358 + * @param \WP_User $user
359 + */
360 + if (!apply_filters('fluent_support/link_customer_on_user_register', true, $user)) {
361 + return;
362 + }
363 +
364 + Customer::where('email', $user->user_email)
365 + ->unclaimed()
366 + ->update(['user_id' => $user->ID]);
367 + }
368 +
369 + /**
370 + * @param string $email
371 + * @param int $customerId
372 + * @return bool
373 + */
374 + protected static function isEmailHeldByAnotherCustomer($email, $customerId)
375 + {
376 + if (!$email) {
377 + return true;
378 + }
379 +
380 + return (bool) Customer::where('email', $email)
381 + ->where('id', '!=', $customerId)
382 + ->first();
104 383 }
105 384 }