PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.8
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.8
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / user-edit-window / rest.php

rest.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.9.8, at includes/user-edit-window/rest.php

709 lines 23.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — Native User Edit Window: insights endpoint.
4 *
5 * `GET /desktop-mode/v1/users/<id>/insights` — returns a single
6 * payload with everything the Insights tab needs:
7 *
8 * - profileCompleteness: filled vs total core fields, percent
9 * - stats: posts / pages / comments / media / approved-comments
10 * received on own posts / days since registration / last login
11 * - contentByMonth: last 12 months of posts authored (for the
12 * mini activity chart)
13 * - recentPosts: last 5 posts the user authored, with status +
14 * comment count
15 * - recentComments: last 5 comments the user wrote (NOT comments
16 * ON their posts — comments BY them, including on their own
17 * content)
18 * - sessions: count of active session tokens (from
19 * `WP_Session_Tokens`); `current` flagged when known
20 * - applicationPasswords: count + most-recently-used summary
21 * - lastLoginAt: UTC unix timestamp from
22 * `_desktop_mode_last_login_at` user meta
23 *
24 * Server-side caching: each user's insights are computed at most
25 * once per minute (transient cache keyed by user_id). The numbers
26 * are eventually-consistent — cheaper than re-running 6 SQL
27 * aggregates on every form interaction.
28 *
29 * @package WPDesktopMode
30 */
31
32 defined( 'ABSPATH' ) || exit;
33
34 /**
35 * Register the route.
36 */
37 function desktop_mode_user_edit_window_register_rest_routes() {
38 register_rest_route(
39 'desktop-mode/v1',
40 '/users/(?P<id>\d+)/insights',
41 array(
42 'methods' => WP_REST_Server::READABLE,
43 'callback' => 'desktop_mode_user_edit_window_rest_insights',
44 'permission_callback' => static function ( $req ) {
45 $id = (int) $req->get_param( 'id' );
46 return desktop_mode_user_edit_window_can_edit(
47 (int) get_current_user_id(),
48 $id
49 );
50 },
51 'args' => array(
52 'id' => array(
53 'required' => true,
54 'type' => 'integer',
55 ),
56 'fresh' => array(
57 'type' => 'boolean',
58 ),
59 ),
60 )
61 );
62 }
63 add_action( 'rest_api_init', 'desktop_mode_user_edit_window_register_rest_routes' );
64
65 /**
66 * Register the personal-options user-meta keys with `show_in_rest`
67 * so the Profile form can save them via core's
68 * `PUT /wp/v2/users/<id>` `meta` field.
69 *
70 * Without this, the meta keys exist (core uses them on the
71 * classic profile.php save) but the REST controller ignores
72 * `meta.rich_editing` etc. on update.
73 */
74 function desktop_mode_user_edit_window_register_meta() {
75 $keys = array(
76 'rich_editing' => 'string',
77 'syntax_highlighting' => 'string',
78 'admin_color' => 'string',
79 'comment_shortcuts' => 'string',
80 'show_admin_bar_front' => 'string',
81 );
82 foreach ( $keys as $meta_key => $type ) {
83 register_meta(
84 'user',
85 $meta_key,
86 array(
87 'type' => $type,
88 'single' => true,
89 'show_in_rest' => array(
90 'schema' => array(
91 'type' => $type,
92 'context' => array( 'view', 'edit' ),
93 ),
94 ),
95 'auth_callback' => static function ( $allowed, $meta_key2, $user_id ) {
96 unset( $meta_key2 );
97 return current_user_can( 'edit_user', (int) $user_id );
98 },
99 'sanitize_callback' => 'sanitize_text_field',
100 )
101 );
102 }
103 }
104 add_action( 'init', 'desktop_mode_user_edit_window_register_meta' );
105
106 /**
107 * `POST /users/<id>/destroy-other-sessions` — log out everywhere
108 * else (current device kept). Mirrors the WP-core
109 * `destroy-sessions` AJAX action.
110 */
111 function desktop_mode_user_edit_window_destroy_sessions_route() {
112 register_rest_route(
113 'desktop-mode/v1',
114 '/users/(?P<id>\d+)/destroy-sessions',
115 array(
116 'methods' => WP_REST_Server::CREATABLE,
117 'callback' => 'desktop_mode_user_edit_window_rest_destroy_sessions',
118 'permission_callback' => static function ( $req ) {
119 $id = (int) $req->get_param( 'id' );
120 return desktop_mode_user_edit_window_can_edit(
121 (int) get_current_user_id(),
122 $id
123 );
124 },
125 'args' => array(
126 'id' => array( 'required' => true, 'type' => 'integer' ),
127 'scope' => array( 'type' => 'string', 'default' => 'others' ),
128 ),
129 )
130 );
131 }
132 add_action( 'rest_api_init', 'desktop_mode_user_edit_window_destroy_sessions_route' );
133
134 function desktop_mode_user_edit_window_rest_destroy_sessions( $req ) {
135 $id = (int) $req->get_param( 'id' );
136 $scope = (string) $req->get_param( 'scope' );
137 if ( ! class_exists( 'WP_Session_Tokens' ) ) {
138 return new WP_Error(
139 'desktop_mode_users_no_sessions',
140 __( 'Session manager unavailable.', 'desktop-mode' ),
141 array( 'status' => 500 )
142 );
143 }
144 $manager = WP_Session_Tokens::get_instance( $id );
145 if ( $scope === 'all' || $id !== (int) get_current_user_id() ) {
146 // Editing another user — destroy ALL of their sessions.
147 // Editing self with scope='all' — destroy all (including
148 // the current). Note the latter logs the requester out.
149 $manager->destroy_all();
150 } else {
151 $manager->destroy_others( wp_get_session_token() );
152 }
153 // Bust the insights cache so the sessions count refreshes.
154 delete_transient( 'dm_user_insights_' . $id );
155 return rest_ensure_response( array( 'ok' => true ) );
156 }
157
158 /**
159 * `GET /users/<id>/application-passwords` — list app passwords.
160 * `POST /users/<id>/application-passwords` — create new.
161 * `DELETE /users/<id>/application-passwords/<uuid>` — revoke one.
162 *
163 * Thin wrappers over `WP_Application_Passwords` so the form has a
164 * single REST surface to talk to.
165 */
166 function desktop_mode_user_edit_window_app_passwords_routes() {
167 register_rest_route(
168 'desktop-mode/v1',
169 '/users/(?P<id>\d+)/application-passwords',
170 array(
171 array(
172 'methods' => WP_REST_Server::READABLE,
173 'callback' => 'desktop_mode_user_edit_window_rest_app_pw_list',
174 'permission_callback' => static function ( $req ) {
175 return desktop_mode_user_edit_window_can_edit(
176 (int) get_current_user_id(),
177 (int) $req->get_param( 'id' )
178 );
179 },
180 ),
181 array(
182 'methods' => WP_REST_Server::CREATABLE,
183 'callback' => 'desktop_mode_user_edit_window_rest_app_pw_create',
184 'permission_callback' => static function ( $req ) {
185 return desktop_mode_user_edit_window_can_edit(
186 (int) get_current_user_id(),
187 (int) $req->get_param( 'id' )
188 );
189 },
190 'args' => array(
191 'name' => array( 'required' => true, 'type' => 'string' ),
192 ),
193 ),
194 )
195 );
196 register_rest_route(
197 'desktop-mode/v1',
198 '/users/(?P<id>\d+)/application-passwords/(?P<uuid>[a-f0-9-]+)',
199 array(
200 'methods' => WP_REST_Server::DELETABLE,
201 'callback' => 'desktop_mode_user_edit_window_rest_app_pw_revoke',
202 'permission_callback' => static function ( $req ) {
203 return desktop_mode_user_edit_window_can_edit(
204 (int) get_current_user_id(),
205 (int) $req->get_param( 'id' )
206 );
207 },
208 )
209 );
210 }
211 add_action( 'rest_api_init', 'desktop_mode_user_edit_window_app_passwords_routes' );
212
213 /**
214 * Enforce core's application-password availability policy for a
215 * target user. Mirrors `WP_REST_Application_Passwords_Controller`'s
216 * permission check: every operation is rejected when the feature is
217 * disabled site-wide (`wp_is_application_passwords_available()`) or
218 * for the target user
219 * (`wp_is_application_passwords_available_for_user()`) — both of
220 * which are filterable by security plugins.
221 *
222 * @param int $user_id Target user id.
223 * @return WP_Error|null Error when unavailable, null when allowed.
224 */
225 function desktop_mode_user_edit_window_app_pw_unavailable( $user_id ) {
226 if (
227 ! function_exists( 'wp_is_application_passwords_available' )
228 || ! wp_is_application_passwords_available()
229 || ! wp_is_application_passwords_available_for_user( (int) $user_id )
230 ) {
231 return new WP_Error(
232 'desktop_mode_users_app_pw_unavailable',
233 __( 'Application passwords are not available for this user.', 'desktop-mode' ),
234 array( 'status' => 501 )
235 );
236 }
237 return null;
238 }
239
240 function desktop_mode_user_edit_window_rest_app_pw_list( $req ) {
241 if ( ! class_exists( 'WP_Application_Passwords' ) ) {
242 return rest_ensure_response( array( 'items' => array() ) );
243 }
244 $id = (int) $req->get_param( 'id' );
245 $unavailable = desktop_mode_user_edit_window_app_pw_unavailable( $id );
246 if ( is_wp_error( $unavailable ) ) {
247 return $unavailable;
248 }
249 $apps = (array) WP_Application_Passwords::get_user_application_passwords( $id );
250 return rest_ensure_response( array( 'items' => $apps ) );
251 }
252
253 function desktop_mode_user_edit_window_rest_app_pw_create( $req ) {
254 if ( ! class_exists( 'WP_Application_Passwords' ) ) {
255 return new WP_Error(
256 'desktop_mode_users_app_pw_unavailable',
257 __( 'Application passwords are not available on this site.', 'desktop-mode' ),
258 array( 'status' => 501 )
259 );
260 }
261 $id = (int) $req->get_param( 'id' );
262 $unavailable = desktop_mode_user_edit_window_app_pw_unavailable( $id );
263 if ( is_wp_error( $unavailable ) ) {
264 return $unavailable;
265 }
266 $name = sanitize_text_field( (string) $req->get_param( 'name' ) );
267 if ( '' === $name ) {
268 return new WP_Error(
269 'desktop_mode_users_app_pw_name_required',
270 __( 'Application password name is required.', 'desktop-mode' ),
271 array( 'status' => 400 )
272 );
273 }
274 $created = WP_Application_Passwords::create_new_application_password( $id, array( 'name' => $name ) );
275 if ( is_wp_error( $created ) ) {
276 return $created;
277 }
278 list( $unhashed_password, $item ) = $created;
279 delete_transient( 'dm_user_insights_' . $id );
280 return rest_ensure_response(
281 array(
282 'ok' => true,
283 'password' => $unhashed_password,
284 'item' => $item,
285 )
286 );
287 }
288
289 function desktop_mode_user_edit_window_rest_app_pw_revoke( $req ) {
290 if ( ! class_exists( 'WP_Application_Passwords' ) ) {
291 return new WP_Error(
292 'desktop_mode_users_app_pw_unavailable',
293 __( 'Application passwords are not available on this site.', 'desktop-mode' ),
294 array( 'status' => 501 )
295 );
296 }
297 $id = (int) $req->get_param( 'id' );
298 $unavailable = desktop_mode_user_edit_window_app_pw_unavailable( $id );
299 if ( is_wp_error( $unavailable ) ) {
300 return $unavailable;
301 }
302 $uuid = (string) $req->get_param( 'uuid' );
303 $ok = WP_Application_Passwords::delete_application_password( $id, $uuid );
304 if ( is_wp_error( $ok ) ) {
305 return $ok;
306 }
307 delete_transient( 'dm_user_insights_' . $id );
308 return rest_ensure_response( array( 'ok' => true ) );
309 }
310
311 /**
312 * `GET /users/<id>/insights` callback.
313 *
314 * @param WP_REST_Request $req
315 * @return WP_REST_Response|WP_Error
316 */
317 function desktop_mode_user_edit_window_rest_insights( $req ) {
318 $id = (int) $req->get_param( 'id' );
319 $user = $id > 0 ? get_userdata( $id ) : null;
320 if ( ! $user instanceof WP_User ) {
321 return new WP_Error(
322 'desktop_mode_users_not_found',
323 __( 'User not found.', 'desktop-mode' ),
324 array( 'status' => 404 )
325 );
326 }
327
328 $fresh = (bool) $req->get_param( 'fresh' );
329 $cache_key = 'dm_user_insights_' . $id;
330 $payload = null;
331 if ( ! $fresh ) {
332 $cached = get_transient( $cache_key );
333 if ( is_array( $cached ) ) {
334 $payload = $cached;
335 }
336 }
337 if ( null === $payload ) {
338 $payload = desktop_mode_user_edit_window_compute_insights( $user );
339 }
340
341 // Self-view override — the viewer is by definition logged in
342 // right now since they're staring at their own profile. If the
343 // `_desktop_mode_last_login_at` meta isn't populated yet
344 // (account predates the plugin install, or the wp_login hook
345 // fires after the first profile open on a single-tick login)
346 // the tile would read "Never" — contradicting the obvious. Pin
347 // to current time and lazily backfill the meta so future reads
348 // no longer depend on this override. Lives OUTSIDE
349 // `compute_insights` so it applies on cache hits AS WELL AS
350 // fresh computes.
351 $viewer_id = (int) get_current_user_id();
352 if ( $id === $viewer_id ) {
353 $now = time();
354 $stored = (int) get_user_meta(
355 $id,
356 defined( 'DESKTOP_MODE_LAST_LOGIN_META_KEY' )
357 ? DESKTOP_MODE_LAST_LOGIN_META_KEY
358 : '_desktop_mode_last_login_at',
359 true
360 );
361 if ( $stored <= 0 ) {
362 update_user_meta(
363 $id,
364 defined( 'DESKTOP_MODE_LAST_LOGIN_META_KEY' )
365 ? DESKTOP_MODE_LAST_LOGIN_META_KEY
366 : '_desktop_mode_last_login_at',
367 $now
368 );
369 $stored = $now;
370 }
371 // Always reflect the truth on the payload — cached payloads
372 // from before the meta-backfill would otherwise still carry
373 // the stale `null`.
374 if ( ! isset( $payload['stats'] ) || ! is_array( $payload['stats'] ) ) {
375 $payload['stats'] = array();
376 }
377 if ( empty( $payload['stats']['lastLoginAt'] ) ) {
378 $payload['stats']['lastLoginAt'] = $stored;
379 $payload['stats']['daysSinceLastLogin'] = max(
380 0,
381 (int) floor( ( $now - $stored ) / DAY_IN_SECONDS )
382 );
383 }
384 }
385
386 /**
387 * Filter the insights payload before it's returned and cached.
388 *
389 * Plugins can append their own metrics (security-event counts,
390 * subscription tier, last-orders-placed, …) by extending the
391 * `stats` map or adding new top-level keys. The JS bundle
392 * tolerates unknown keys — they're surfaced as plugin tiles
393 * when they match the expected shape.
394 *
395 * @param array $payload Insights payload.
396 * @param WP_User $user Target user.
397 */
398 $payload = (array) apply_filters( 'desktop_mode_user_edit_window_insights', $payload, $user );
399
400 set_transient( $cache_key, $payload, MINUTE_IN_SECONDS );
401
402 return rest_ensure_response( $payload );
403 }
404
405 /**
406 * Compute the insights payload for a user. Centralized so plugins
407 * can call it directly from a custom REST route or admin notice
408 * without going through the HTTP cycle.
409 *
410 * @param WP_User $user
411 * @return array
412 */
413 function desktop_mode_user_edit_window_compute_insights( WP_User $user ) {
414 $id = (int) $user->ID;
415
416 // ── Profile completeness — count which core fields are non-empty.
417 $completeness_fields = array(
418 'first_name' => (string) $user->first_name,
419 'last_name' => (string) $user->last_name,
420 'nickname' => (string) $user->nickname,
421 'description' => (string) $user->description,
422 'user_url' => (string) $user->user_url,
423 'user_email' => (string) $user->user_email,
424 );
425 $filled = 0;
426 foreach ( $completeness_fields as $value ) {
427 if ( '' !== trim( $value ) ) {
428 $filled++;
429 }
430 }
431 $total = count( $completeness_fields );
432 $percent = $total > 0 ? (int) round( ( $filled / $total ) * 100 ) : 0;
433
434 // ── Per-CPT post counts. `count_user_posts` does the cheap thing.
435 $post_count = (int) count_user_posts( $id, 'post', true );
436 $page_count = post_type_exists( 'page' )
437 ? (int) count_user_posts( $id, 'page', true )
438 : 0;
439 $attachment_count = (int) count_user_posts( $id, 'attachment', true );
440
441 // ── Comments authored by this user (not received).
442 $comment_count = (int) get_comments(
443 array(
444 'user_id' => $id,
445 'count' => true,
446 )
447 );
448
449 // ── Approved comments RECEIVED on this user's published posts.
450 // Cheap aggregate — one COUNT, no row hydration.
451 global $wpdb;
452 $received_comments = (int) $wpdb->get_var(
453 $wpdb->prepare(
454 "SELECT COUNT(c.comment_ID)
455 FROM {$wpdb->comments} c
456 INNER JOIN {$wpdb->posts} p
457 ON p.ID = c.comment_post_ID
458 WHERE p.post_author = %d
459 AND p.post_status = 'publish'
460 AND c.comment_approved = '1'",
461 $id
462 )
463 );
464
465 // ── Months for the activity sparkline. Bucket published posts
466 // by year-month for the last 12 months. SQL bucket → align in PHP.
467 $month_buckets = array();
468 $now = time();
469 for ( $i = 11; $i >= 0; $i-- ) {
470 $ts = strtotime( "-{$i} months", $now );
471 $key = gmdate( 'Y-m', $ts );
472 $month_buckets[ $key ] = 0;
473 }
474 $rows = (array) $wpdb->get_results(
475 $wpdb->prepare(
476 "SELECT DATE_FORMAT( post_date_gmt, '%%Y-%%m' ) AS bucket,
477 COUNT(*) AS cnt
478 FROM {$wpdb->posts}
479 WHERE post_author = %d
480 AND post_status IN ( 'publish', 'private', 'future' )
481 AND post_date_gmt >= %s
482 GROUP BY bucket
483 ORDER BY bucket ASC",
484 $id,
485 gmdate( 'Y-m-01 00:00:00', strtotime( '-12 months', $now ) )
486 ),
487 ARRAY_A
488 );
489 foreach ( $rows as $row ) {
490 $bucket = isset( $row['bucket'] ) ? (string) $row['bucket'] : '';
491 if ( isset( $month_buckets[ $bucket ] ) ) {
492 $month_buckets[ $bucket ] = (int) $row['cnt'];
493 }
494 }
495 $content_by_month = array();
496 foreach ( $month_buckets as $bucket => $cnt ) {
497 $content_by_month[] = array(
498 'month' => $bucket,
499 'count' => $cnt,
500 );
501 }
502
503 // ── Recent posts (any status). Limit 5.
504 $recent_posts = array();
505 $recent = get_posts(
506 array(
507 'author' => $id,
508 'post_type' => 'any',
509 'post_status' => array(
510 'publish',
511 'draft',
512 'pending',
513 'future',
514 'private',
515 ),
516 'posts_per_page' => 5,
517 'orderby' => 'date',
518 'order' => 'DESC',
519 )
520 );
521 foreach ( $recent as $post ) {
522 // `post_date_gmt` is `'0000-00-00 00:00:00'` for drafts that
523 // have never been published — the JS Date.parse of that
524 // returns NaN, and the previous fallback rendered every
525 // draft's "recent activity" timestamp as "just now". Use
526 // `get_gmt_from_date( post_date )` to convert the always-set
527 // local `post_date` to UTC when the GMT field is zero.
528 $gmt = (string) $post->post_date_gmt;
529 if ( '' === $gmt || 0 === strpos( $gmt, '0000-00-00' ) ) {
530 $gmt = (string) get_gmt_from_date( (string) $post->post_date );
531 }
532 $recent_posts[] = array(
533 'id' => (int) $post->ID,
534 'title' => $post->post_title !== ''
535 ? $post->post_title
536 : __( '(no title)', 'desktop-mode' ),
537 'status' => (string) $post->post_status,
538 'type' => (string) $post->post_type,
539 'dateGmt' => $gmt,
540 'commentCount' => (int) $post->comment_count,
541 'permalink' => (string) get_permalink( $post ),
542 'editUrl' => (string) get_edit_post_link( $post->ID, 'raw' ),
543 );
544 }
545
546 // ── Recent comments authored by this user. Limit 5.
547 $recent_comments = array();
548 $comments = get_comments(
549 array(
550 'user_id' => $id,
551 'number' => 5,
552 'orderby' => 'comment_date_gmt',
553 'order' => 'DESC',
554 )
555 );
556 foreach ( (array) $comments as $comment ) {
557 $post_title = '';
558 if ( $comment->comment_post_ID ) {
559 $post = get_post( (int) $comment->comment_post_ID );
560 if ( $post instanceof WP_Post ) {
561 $post_title = $post->post_title !== ''
562 ? $post->post_title
563 : __( '(no title)', 'desktop-mode' );
564 }
565 }
566 // Same zero-date fallback as recent posts above.
567 $comment_gmt = (string) $comment->comment_date_gmt;
568 if ( '' === $comment_gmt || 0 === strpos( $comment_gmt, '0000-00-00' ) ) {
569 $comment_gmt = (string) get_gmt_from_date(
570 (string) $comment->comment_date
571 );
572 }
573 $recent_comments[] = array(
574 'id' => (int) $comment->comment_ID,
575 'postId' => (int) $comment->comment_post_ID,
576 'postTitle' => $post_title,
577 'excerpt' => wp_trim_words(
578 wp_strip_all_tags( (string) $comment->comment_content ),
579 24
580 ),
581 'dateGmt' => $comment_gmt,
582 'approved' => '1' === (string) $comment->comment_approved,
583 );
584 }
585
586 // ── Active sessions (`WP_Session_Tokens`). The token bag is a
587 // blob of metadata per device — UA / IP / login + expiration. We
588 // surface a per-session row plus a current-session flag.
589 $sessions = array();
590 if ( class_exists( 'WP_Session_Tokens' ) ) {
591 $manager = WP_Session_Tokens::get_instance( $id );
592 $current_token = wp_get_session_token();
593 // The meta blob's keys are *verifiers* — hashes of the raw
594 // cookie token (`WP_Session_Tokens::hash_token()`), so hash
595 // the current token the same way before comparing.
596 $current_verifier = '';
597 if ( $current_token ) {
598 $current_verifier = function_exists( 'hash' )
599 ? hash( 'sha256', $current_token )
600 : sha1( $current_token );
601 }
602 // `get_all` returns the tokens-as-array but doesn't expose
603 // the token id — peek into the meta blob via the user meta
604 // key directly so we can flag the "current" session.
605 $raw_tokens = (array) get_user_meta( $id, 'session_tokens', true );
606 // Prune expired entries the same way `WP_Session_Tokens`
607 // does on its own write path (so a user who hasn't logged
608 // in for a while doesn't show stale device rows).
609 $now_ts = time();
610 foreach ( $raw_tokens as $hash => $info ) {
611 if ( ! is_array( $info ) ) {
612 continue;
613 }
614 $expires = isset( $info['expiration'] ) ? (int) $info['expiration'] : 0;
615 if ( $expires > 0 && $expires < $now_ts ) {
616 continue;
617 }
618 $sessions[] = array(
619 'expiration' => $expires,
620 'login' => isset( $info['login'] ) ? (int) $info['login'] : 0,
621 'ip' => isset( $info['ip'] ) ? (string) $info['ip'] : '',
622 'ua' => isset( $info['ua'] ) ? (string) $info['ua'] : '',
623 'current' => '' !== $current_verifier && $current_verifier === $hash,
624 );
625 }
626 unset( $manager ); // unused but instantiated for symmetry / future use.
627 }
628
629 // ── Application passwords (WordPress 5.6+). Stored as user meta.
630 $app_passwords_summary = array(
631 'total' => 0,
632 'lastUsedAt' => null,
633 'lastUsedName' => null,
634 );
635 if ( class_exists( 'WP_Application_Passwords' ) ) {
636 $apps = WP_Application_Passwords::get_user_application_passwords( $id );
637 $apps = is_array( $apps ) ? $apps : array();
638 $app_passwords_summary['total'] = count( $apps );
639 $best_used_ts = 0;
640 $best_used_name = null;
641 foreach ( $apps as $app ) {
642 $used = isset( $app['last_used'] ) ? (int) $app['last_used'] : 0;
643 if ( $used > $best_used_ts ) {
644 $best_used_ts = $used;
645 $best_used_name = isset( $app['name'] ) ? (string) $app['name'] : null;
646 }
647 }
648 if ( $best_used_ts > 0 ) {
649 $app_passwords_summary['lastUsedAt'] = $best_used_ts;
650 $app_passwords_summary['lastUsedName'] = $best_used_name;
651 }
652 }
653
654 // ── Misc / temporal stats.
655 $registered_ts = strtotime( (string) $user->user_registered . ' UTC' );
656 $days_since_registration = $registered_ts
657 ? max( 0, (int) floor( ( time() - $registered_ts ) / DAY_IN_SECONDS ) )
658 : null;
659 $last_login_ts = (int) get_user_meta(
660 $id,
661 defined( 'DESKTOP_MODE_LAST_LOGIN_META_KEY' )
662 ? DESKTOP_MODE_LAST_LOGIN_META_KEY
663 : '_desktop_mode_last_login_at',
664 true
665 );
666 $last_login_ts = $last_login_ts > 0 ? $last_login_ts : null;
667 $days_since_last_login = $last_login_ts
668 ? max( 0, (int) floor( ( time() - $last_login_ts ) / DAY_IN_SECONDS ) )
669 : null;
670
671 // ── Roles + capabilities count for the profile chip strip.
672 $roles = array_values( (array) $user->roles );
673 $caps_count = is_array( $user->allcaps ) ? count(
674 array_filter( $user->allcaps, static function ( $v ) {
675 return (bool) $v;
676 } )
677 ) : 0;
678
679 return array(
680 'userId' => $id,
681 'displayName' => (string) $user->display_name,
682 'avatarUrl' => (string) get_avatar_url( $id, array( 'size' => 96 ) ),
683 'profileUrl' => (string) get_author_posts_url( $id ),
684 'roles' => $roles,
685 'capabilitiesCount' => $caps_count,
686 'profileCompleteness' => array(
687 'filled' => $filled,
688 'total' => $total,
689 'percent' => $percent,
690 ),
691 'stats' => array(
692 'posts' => $post_count,
693 'pages' => $page_count,
694 'attachments' => $attachment_count,
695 'commentsAuthored' => $comment_count,
696 'commentsReceived' => $received_comments,
697 'daysSinceRegistration' => $days_since_registration,
698 'lastLoginAt' => $last_login_ts,
699 'daysSinceLastLogin' => $days_since_last_login,
700 'registeredAt' => $registered_ts ?: null,
701 ),
702 'contentByMonth' => $content_by_month,
703 'recentPosts' => $recent_posts,
704 'recentComments' => $recent_comments,
705 'sessions' => $sessions,
706 'applicationPasswords' => $app_passwords_summary,
707 );
708 }
709