__( 'Users', 'desktop-mode' ), 'icon' => 'dashicons-admin-users', 'template' => 'openstation_users_window_render_template', // Reuse the Posts bundle — same script + style handles. The // shared module branches on `cfg.mode` to render the Users // view. 'script' => 'os-posts-window', 'style' => 'os-posts-window', 'width' => 1100, 'height' => 720, 'min_width' => 720, 'min_height' => 480, 'placement' => 'none', 'config' => array( 'mode' => 'users', 'restRoot' => esc_url_raw( rest_url() ), 'restNonce' => wp_create_nonce( 'wp_rest' ), 'postsUrl' => esc_url_raw( rest_url( 'wp/v2/users' ) ), 'editPostUrlBase' => esc_url_raw( admin_url( 'user-edit.php' ) ), 'newPostUrl' => esc_url_raw( admin_url( 'user-new.php' ) ), 'usersUrl' => esc_url_raw( rest_url( 'wp/v2/users' ) ), 'currentUserId' => $viewer_id, 'defaultPerPage' => 20, 'queryArgs' => openstation_users_window_default_query_args(), // Capability flags surfaced to the JS — UI hides actions // the viewer can't perform. Server still re-checks every // mutation, so a tampered flag here changes nothing // security-wise. 'canEdit' => current_user_can( 'edit_users' ), 'canPromote' => current_user_can( 'promote_users' ), 'canCreate' => current_user_can( 'create_users' ), 'canDelete' => is_multisite() ? current_user_can( 'remove_users' ) : current_user_can( 'delete_users' ), 'isMultisite' => is_multisite(), // Role list — `{ slug: name }` for every role the viewer // can assign. Empty when the viewer lacks `promote_users`. 'assignableRoles' => openstation_users_window_role_label_map( $viewer_id ), // Full role catalog for the role-FILTER dropdown (which // shows EVERY role on the site, even those the viewer // can't assign — they can still filter by them). 'allRoles' => openstation_users_window_all_roles_map(), // Available site locales for the Add User form's // language dropdown. `'site-default'` = empty string // (the user inherits the site's locale). 'locales' => openstation_users_window_locales_map(), 'siteLocale' => (string) get_locale(), 'defaultRole' => (string) get_option( 'default_role', 'subscriber' ), 'createUserUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/users' ) ), // REST mutation routes — the JS bundle reads these so a // rename or namespace move stays in one place. 'bulkRoleUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/users/bulk-role' ) ), 'bulkDeleteUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/users/bulk-delete' ) ), // Profile sub-tab — uses the same config blob to read // the user-edit field option lists, the insights // endpoint base, and the locale/role maps. 'insightsUrlBase' => esc_url_raw( rest_url( 'desktop-mode/v1/users/' ) ), /** This filter is documented in wp-includes/user.php */ // phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core's filter; the window must offer the same contact fields profile.php does. 'contactMethods' => (array) apply_filters( 'user_contactmethods', array(), null ), // phpcs:enable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound 'colorSchemes' => function_exists( 'openstation_user_edit_window_color_schemes' ) ? openstation_user_edit_window_color_schemes() : array(), 'sendResetUrlBase' => esc_url_raw( rest_url( 'desktop-mode/v1/users/' ) ), ), ); /** * Filter the args used to register the native Users window. * * @param array $window_args Args passed to `openstation_register_window()`. */ $window_args = (array) apply_filters( 'openstation_users_window_args', $window_args ); $registered = openstation_register_window( 'desktop-mode-users', $window_args ); if ( is_wp_error( $registered ) ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log( '[openstation] Native Users window registration failed: ' . $registered->get_error_message() ); } } add_action( 'init', 'openstation_users_window_register_window', 20 ); /** * Default REST query args for the Users window. * * @return array */ function openstation_users_window_default_query_args() { $args = array( // `_fields` whitelists the columns we render plus the four // REST fields registered below. Skipping the whitelist would // pull every meta + every embedded link the user controller // emits — heavy on every page change. '_fields' => 'id,name,slug,email,url,description,roles,registered_date,avatar_urls,' . 'openstation_user_stats,openstation_last_login,openstation_presence,' . 'openstation_can_edit,openstation_assignable_roles', // `who=authors` would hide subscribers — we want the full // list. `context=edit` is required because `email`, `roles`, // and `registered_date` are edit-context-only on // `/wp/v2/users`; in `view` they're omitted from the response // entirely (independent of `_fields`), which paints the // table as "No role" / empty email / empty registered date. // The window is already gated on `list_users`, the cap // `context=edit` requires, so this is safe. 'context' => 'edit', 'per_page' => 20, ); /** * Filter the default outbound REST query args for the Users window. * * @param array $args Default args. */ return (array) apply_filters( 'openstation_users_window_query_args', $args ); } /** * Build the `{ slug: label }` map for every role on the install. * * Used by the Users window's role FILTER (vs. role-CHANGE menu — * see {@see openstation_users_window_role_label_map()} for that). * * @return array */ function openstation_users_window_all_roles_map() { $roles = wp_roles(); $map = array(); foreach ( (array) $roles->roles as $slug => $info ) { $map[ (string) $slug ] = isset( $info['name'] ) ? translate_user_role( (string) $info['name'] ) : (string) $slug; } return $map; } /** * Build the `{ slug: label }` map for roles the viewer is allowed * to assign. Empty when the viewer lacks `promote_users`. * * @param int $viewer_id Viewer's user id. * @return array */ function openstation_users_window_role_label_map( $viewer_id ) { $slugs = openstation_users_window_assignable_roles( (int) $viewer_id ); if ( empty( $slugs ) ) { return array(); } $all = openstation_users_window_all_roles_map(); $out = array(); foreach ( $slugs as $slug ) { if ( isset( $all[ $slug ] ) ) { $out[ $slug ] = $all[ $slug ]; } } return $out; } /** * Register the Users-window REST fields on the `user` resource. * * Fields: * * - openstation_user_stats — `{ posts: int, pages: int, comments: int }` * - openstation_last_login — UTC unix timestamp, or null when never * - openstation_presence — 'online' | 'inactive' | 'offline' * - openstation_can_edit — viewer can edit / promote this row * - openstation_assignable_roles — role slugs the viewer can assign to this row * * Each field returns sensible empty defaults when the viewer lacks * the cap to see the value, so the JS never has to defend against * "field present but null". The fields register on every REST request * (the `user` resource is partially public — published authors are * visible to anyone), so `openstation_last_login` and * `openstation_presence` gate on `list_users` (or self) inside their * callbacks; `openstation_user_stats` stays open because it only * counts published content. */ function openstation_users_window_register_rest_fields() { register_rest_field( 'user', 'openstation_user_stats', array( 'get_callback' => static function ( $row ) { $id = isset( $row['id'] ) ? (int) $row['id'] : 0; if ( $id <= 0 ) { return array( 'posts' => 0, 'pages' => 0, 'comments' => 0, ); } $posts = (int) count_user_posts( $id, 'post', true ); $pages = post_type_exists( 'page' ) ? (int) count_user_posts( $id, 'page', true ) : 0; $comments = (int) get_comments( array( 'user_id' => $id, 'count' => true, 'status' => 'approve', ) ); return array( 'posts' => $posts, 'pages' => $pages, 'comments' => $comments, ); }, 'schema' => array( 'description' => __( 'Per-user content stats: published post / page / comment counts.', 'desktop-mode' ), 'type' => 'object', 'context' => array( 'view', 'edit', 'embed' ), 'readonly' => true, ), ) ); register_rest_field( 'user', 'openstation_last_login', array( 'get_callback' => static function ( $row ) { $id = isset( $row['id'] ) ? (int) $row['id'] : 0; if ( $id <= 0 ) { return null; } // Last-login time is sensitive. Only viewers who can see // the Users list — or the user themselves — get the // real value. if ( get_current_user_id() !== $id && ! current_user_can( 'list_users' ) ) { return null; } $ts = (int) get_user_meta( $id, OPENSTATION_LAST_LOGIN_META_KEY, true ); return $ts > 0 ? $ts : null; }, 'schema' => array( 'description' => __( 'UTC unix timestamp of this user’s last successful login, or null when never recorded.', 'desktop-mode' ), 'type' => array( 'integer', 'null' ), 'context' => array( 'view', 'edit', 'embed' ), 'readonly' => true, ), ) ); register_rest_field( 'user', 'openstation_presence', array( 'get_callback' => static function ( $row ) { $id = isset( $row['id'] ) ? (int) $row['id'] : 0; if ( $id <= 0 || ! function_exists( 'openstation_presence_status_for_user' ) ) { return 'offline'; } // Live presence is sensitive. Only viewers who can see // the Users list — or the user themselves — get the // real value. if ( get_current_user_id() !== $id && ! current_user_can( 'list_users' ) ) { return 'offline'; } return (string) openstation_presence_status_for_user( $id ); }, 'schema' => array( 'description' => __( 'Live presence status: online / inactive / offline.', 'desktop-mode' ), 'type' => 'string', 'enum' => array( 'online', 'inactive', 'offline' ), 'context' => array( 'view', 'edit', 'embed' ), 'readonly' => true, ), ) ); register_rest_field( 'user', 'openstation_can_edit', array( 'get_callback' => static function ( $row ) { $id = isset( $row['id'] ) ? (int) $row['id'] : 0; $viewer = (int) get_current_user_id(); if ( $id <= 0 || $viewer <= 0 ) { return false; } return (bool) user_can( $viewer, 'edit_user', $id ); }, 'schema' => array( 'description' => __( 'Whether the requester can edit this user.', 'desktop-mode' ), 'type' => 'boolean', 'context' => array( 'view', 'edit', 'embed' ), 'readonly' => true, ), ) ); register_rest_field( 'user', 'openstation_assignable_roles', array( 'get_callback' => static function ( $row ) { $id = isset( $row['id'] ) ? (int) $row['id'] : 0; $viewer = (int) get_current_user_id(); if ( $id <= 0 || $viewer <= 0 ) { return array(); } return array_values( openstation_users_window_assignable_roles( $viewer, $id ) ); }, 'schema' => array( 'description' => __( 'Role slugs the requester can assign to this user.', 'desktop-mode' ), 'type' => 'array', 'items' => array( 'type' => 'string' ), 'context' => array( 'view', 'edit', 'embed' ), 'readonly' => true, ), ) ); } add_action( 'rest_api_init', 'openstation_users_window_register_rest_fields' ); /** * Build the `[ slug => label ]` map for the Add User locale picker. * * Site default is keyed under `''` (empty string) so the form can * reflect "Site default — English (United States)" as the default * choice without forcing the user to know which slug to send. * * @return array */ function openstation_users_window_locales_map() { $out = array( '' => sprintf( // translators: %s is the site's current locale (e.g. "en_US"). __( 'Site default — %s', 'desktop-mode' ), get_locale() ), ); if ( ! function_exists( 'get_available_languages' ) ) { require_once ABSPATH . 'wp-admin/includes/translation-install.php'; } $languages = (array) get_available_languages(); foreach ( $languages as $slug ) { $out[ (string) $slug ] = (string) $slug; } // Always offer en_US even if no .mo file is installed — core // always treats it as available. if ( ! isset( $out['en_US'] ) ) { $out['en_US'] = 'en_US'; } return $out; }