__( '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',
'introSlug' => '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(),
'introSeen' => openstation_has_seen_intro( $viewer_id, 'users' ),
'introUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/intros/seen' ) ),
// 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;
}