| 1 |
<?php |
| 2 |
/** |
| 3 |
* Users — the Users window, as an OpenStation app. |
| 4 |
* |
| 5 |
* Claims the FROZEN id `desktop-mode-users` (see AGENTS.md) so the |
| 6 |
* `users.php` URL remap, session restores and every hook keep |
| 7 |
* working. The body is `users.os.ts`, a client view over the rows |
| 8 |
* `data()` reads from `wp/v2/users` in-process — the same collection, |
| 9 |
* fields and filterable query, plus a page of content counts in two |
| 10 |
* grouped queries. The mutations are actions over the functions in |
| 11 |
* `parts/rest.php`, which the `desktop-mode/v1/users*` routes expose |
| 12 |
* too. The Profile tab hosts `<os-user-profile>` from the companion |
| 13 |
* bundle `parts/profile-script.php` registers. |
| 14 |
* |
| 15 |
* (Header kept short on purpose: Plugin Check's direct-access scan |
| 16 |
* reads only the first 50 raw lines, and the guard below must land |
| 17 |
* inside that window.) |
| 18 |
* |
| 19 |
* @package OpenStation |
| 20 |
*/ |
| 21 |
|
| 22 |
namespace OpenStation\Apps\Users; |
| 23 |
|
| 24 |
use OpenStation\App; |
| 25 |
use OpenStation\App\Os; |
| 26 |
use OpenStation\App\State; |
| 27 |
|
| 28 |
// Direct access, unless a standalone host is booting on bare PHP. |
| 29 |
if ( ! defined( 'ABSPATH' ) ) { |
| 30 |
defined( 'OPENSTATION_STANDALONE' ) || exit; |
| 31 |
} |
| 32 |
|
| 33 |
require_once __DIR__ . '/parts/permissions.php'; |
| 34 |
require_once __DIR__ . '/parts/login-tracker.php'; |
| 35 |
require_once __DIR__ . '/parts/color-schemes.php'; |
| 36 |
require_once __DIR__ . '/parts/fields.php'; |
| 37 |
require_once __DIR__ . '/parts/roles-summary.php'; |
| 38 |
require_once __DIR__ . '/parts/activity-summary.php'; |
| 39 |
require_once __DIR__ . '/parts/facts.php'; |
| 40 |
require_once __DIR__ . '/parts/rest.php'; |
| 41 |
require_once __DIR__ . '/parts/profile-script.php'; |
| 42 |
|
| 43 |
/** The `orderby` values `wp/v2/users` accepts that the table offers. */ |
| 44 |
const SORT_KEYS = array( 'name', 'registered_date', 'email' ); |
| 45 |
|
| 46 |
/** |
| 47 |
* The query the list reads — the filterable defaults plus the state. |
| 48 |
* |
| 49 |
* @param State $state State. |
| 50 |
* @return array<string,mixed> |
| 51 |
*/ |
| 52 |
function list_query( State $state ) { |
| 53 |
$query = openstation_users_window_default_query_args(); |
| 54 |
$query['page'] = max( 1, (int) $state->get( 'page' ) ); |
| 55 |
$query['per_page'] = max( 1, (int) $state->get( 'perPage' ) ); |
| 56 |
$query['orderby'] = (string) $state->get( 'orderby' ); |
| 57 |
$query['order'] = (string) $state->get( 'order' ); |
| 58 |
$search = trim( (string) $state->get( 'search' ) ); |
| 59 |
if ( '' !== $search ) { |
| 60 |
$query['search'] = $search; |
| 61 |
} |
| 62 |
$role = trim( (string) $state->get( 'role' ) ); |
| 63 |
if ( 'none' === $role ) { |
| 64 |
// As users.php does: the accounts with no role on this site, or nobody. |
| 65 |
$ids = wp_get_users_with_no_role(); |
| 66 |
$query['include'] = $ids ? array_map( 'intval', $ids ) : array( 0 ); |
| 67 |
} elseif ( '' !== $role ) { |
| 68 |
$query['roles'] = $role; |
| 69 |
} |
| 70 |
return $query; |
| 71 |
} |
| 72 |
|
| 73 |
/** |
| 74 |
* Say what a mutation did. |
| 75 |
* |
| 76 |
* @param Os $os Host handle. |
| 77 |
* @param array|\WP_Error $result The mutation's answer. |
| 78 |
* @param callable $ok `function ( array $result ): string` — the success message. |
| 79 |
* @return bool Whether it succeeded. |
| 80 |
*/ |
| 81 |
function report( Os $os, $result, callable $ok ) { |
| 82 |
if ( is_wp_error( $result ) ) { |
| 83 |
$os->toast( $result->get_error_message() ); |
| 84 |
return false; |
| 85 |
} |
| 86 |
$os->toast( $ok( $result ) ); |
| 87 |
return true; |
| 88 |
} |
| 89 |
|
| 90 |
/** |
| 91 |
* How many of a bulk result's rows succeeded. |
| 92 |
* |
| 93 |
* @param array<string,mixed> $result Bulk result. |
| 94 |
* @return int |
| 95 |
*/ |
| 96 |
function ok_count( array $result ) { |
| 97 |
return count( |
| 98 |
array_filter( |
| 99 |
(array) $result['results'], |
| 100 |
static function ( $row ) { |
| 101 |
return ! empty( $row['ok'] ); |
| 102 |
} |
| 103 |
) |
| 104 |
); |
| 105 |
} |
| 106 |
|
| 107 |
return App::define( 'desktop-mode-users' ) |
| 108 |
->title( __( 'Users', 'desktop-mode' ) ) |
| 109 |
->icon( 'dashicons-admin-users' ) |
| 110 |
->size( 1100, 720 ) |
| 111 |
->min_size( 720, 480 ) |
| 112 |
// The Users dock tile lives in WordPress's `$menu`; the URL remap |
| 113 |
// routes its click here when the opt-in is on. |
| 114 |
->placement( 'none' ) |
| 115 |
->can( |
| 116 |
static function () { |
| 117 |
return openstation_users_window_user_can_register(); |
| 118 |
} |
| 119 |
) |
| 120 |
// Resolved when the window registers, for the viewer registering it. |
| 121 |
->config( 'openstation_users_profile_facts' ) |
| 122 |
->state( |
| 123 |
array( |
| 124 |
'page' => 1, |
| 125 |
'perPage' => 20, |
| 126 |
'search' => '', |
| 127 |
// The role filter: a slug, or `none` for accounts with no |
| 128 |
// role on this site, as `users.php?role=none` spells it. A |
| 129 |
// server-side scope, so a group picked on the Roles tab is |
| 130 |
// a fresh collection rather than a slice of the loaded pages. |
| 131 |
'role' => '', |
| 132 |
// The presence filter (All / Online / Active 30d / Never |
| 133 |
// logged in) — a client-side slice of the page. |
| 134 |
'status' => '', |
| 135 |
'orderby' => 'name', |
| 136 |
'order' => 'asc', |
| 137 |
// The tab strip: `all` | `add-new` | `edit`. |
| 138 |
'tab' => 'all', |
| 139 |
// The Add User form's last failure, for the field it names. |
| 140 |
'createError' => '', |
| 141 |
'createField' => '', |
| 142 |
// Bumped on every successful create — the form resets on it. |
| 143 |
'created' => 0, |
| 144 |
) |
| 145 |
) |
| 146 |
// A query change replaces the result set — back to the first page. |
| 147 |
->action( |
| 148 |
'filter', |
| 149 |
static function ( State $state ) { |
| 150 |
$state->set( 'page', 1 ); |
| 151 |
} |
| 152 |
) |
| 153 |
->action( |
| 154 |
'page', |
| 155 |
static function ( State $state, Os $os, array $args ) { |
| 156 |
$state->set( 'page', max( 1, (int) ( $args['page'] ?? 1 ) ) ); |
| 157 |
} |
| 158 |
) |
| 159 |
// A column header click; the table's keys map to the collection's. |
| 160 |
->action( |
| 161 |
'sort', |
| 162 |
static function ( State $state, Os $os, array $args ) { |
| 163 |
$orderby = sanitize_key( (string) ( $args['orderby'] ?? 'name' ) ); |
| 164 |
$state->set( 'orderby', in_array( $orderby, SORT_KEYS, true ) ? $orderby : 'name' ); |
| 165 |
$state->set( 'order', 'desc' === strtolower( (string) ( $args['order'] ?? 'asc' ) ) ? 'desc' : 'asc' ); |
| 166 |
} |
| 167 |
) |
| 168 |
->action( |
| 169 |
'bulk-role', |
| 170 |
static function ( State $state, Os $os, array $args ) { |
| 171 |
if ( ! $os->can( 'promote_users' ) ) { |
| 172 |
$os->toast( __( 'You are not allowed to change roles.', 'desktop-mode' ) ); |
| 173 |
return; |
| 174 |
} |
| 175 |
$ids = openstation_users_window_clean_ids( $args['ids'] ?? array() ); |
| 176 |
$result = openstation_users_window_apply_bulk_role( $ids, (string) ( $args['role'] ?? '' ) ); |
| 177 |
$done = report( |
| 178 |
$os, |
| 179 |
$result, |
| 180 |
static function ( array $result ) use ( $ids ) { |
| 181 |
$ok = ok_count( $result ); |
| 182 |
if ( 0 === $ok ) { |
| 183 |
return __( 'No users updated.', 'desktop-mode' ); |
| 184 |
} |
| 185 |
// translators: %1$d users updated, %2$d failed. |
| 186 |
return sprintf( __( 'Role updated for %1$d user(s) (%2$d skipped).', 'desktop-mode' ), $ok, count( $ids ) - $ok ); |
| 187 |
} |
| 188 |
); |
| 189 |
if ( $done ) { |
| 190 |
$os->announce( 'user', 'updated', $ids ); |
| 191 |
} |
| 192 |
} |
| 193 |
) |
| 194 |
->action( |
| 195 |
'bulk-delete', |
| 196 |
static function ( State $state, Os $os, array $args ) { |
| 197 |
if ( ! $os->can( is_multisite() ? 'remove_users' : 'delete_users' ) ) { |
| 198 |
$os->toast( __( 'You are not allowed to delete users.', 'desktop-mode' ) ); |
| 199 |
return; |
| 200 |
} |
| 201 |
$ids = openstation_users_window_clean_ids( $args['ids'] ?? array() ); |
| 202 |
$result = openstation_users_window_apply_bulk_delete( $ids, (int) ( $args['reassign'] ?? 0 ) ); |
| 203 |
$done = report( |
| 204 |
$os, |
| 205 |
$result, |
| 206 |
static function ( array $result ) use ( $ids ) { |
| 207 |
$ok = ok_count( $result ); |
| 208 |
// translators: %1$d users deleted, %2$d skipped. |
| 209 |
return sprintf( __( '%1$d user(s) deleted (%2$d skipped).', 'desktop-mode' ), $ok, count( $ids ) - $ok ); |
| 210 |
} |
| 211 |
); |
| 212 |
if ( $done ) { |
| 213 |
$os->announce( 'user', 'deleted', $ids ); |
| 214 |
} |
| 215 |
} |
| 216 |
) |
| 217 |
->action( |
| 218 |
'send-reset', |
| 219 |
static function ( State $state, Os $os, array $args ) { |
| 220 |
report( |
| 221 |
$os, |
| 222 |
$os->can( 'edit_users' ) |
| 223 |
? openstation_users_window_send_password_reset( (int) ( $args['id'] ?? 0 ) ) |
| 224 |
: new \WP_Error( 'openstation_users_forbidden', __( 'You are not allowed to email this user.', 'desktop-mode' ) ), |
| 225 |
static function ( array $result ) { |
| 226 |
// translators: %s is the user's email address. |
| 227 |
return sprintf( __( 'Reset email sent to %s.', 'desktop-mode' ), $result['email'] ); |
| 228 |
} |
| 229 |
); |
| 230 |
} |
| 231 |
) |
| 232 |
->action( |
| 233 |
'resend-welcome', |
| 234 |
static function ( State $state, Os $os, array $args ) { |
| 235 |
report( |
| 236 |
$os, |
| 237 |
$os->can( 'edit_users' ) |
| 238 |
? openstation_users_window_resend_welcome( (int) ( $args['id'] ?? 0 ) ) |
| 239 |
: new \WP_Error( 'openstation_users_forbidden', __( 'You are not allowed to email this user.', 'desktop-mode' ) ), |
| 240 |
static function ( array $result ) { |
| 241 |
// translators: %s is the user's email address. |
| 242 |
return sprintf( __( 'Welcome email resent to %s.', 'desktop-mode' ), $result['email'] ); |
| 243 |
} |
| 244 |
); |
| 245 |
} |
| 246 |
) |
| 247 |
->action( |
| 248 |
'create', |
| 249 |
static function ( State $state, Os $os, array $args ) { |
| 250 |
$state->set( 'createError', '' ); |
| 251 |
$state->set( 'createField', '' ); |
| 252 |
if ( ! $os->can( 'create_users' ) ) { |
| 253 |
$state->set( 'createError', __( 'You are not allowed to create users.', 'desktop-mode' ) ); |
| 254 |
return; |
| 255 |
} |
| 256 |
$values = is_array( $args['values'] ?? null ) ? $args['values'] : $args; |
| 257 |
$result = openstation_users_window_create_user( $values ); |
| 258 |
if ( is_wp_error( $result ) ) { |
| 259 |
$code = $result->get_error_code(); |
| 260 |
$field = ''; |
| 261 |
if ( in_array( $code, array( 'openstation_users_username_exists', 'existing_user_login', 'openstation_users_username_invalid', 'openstation_users_username_required' ), true ) ) { |
| 262 |
$field = 'username'; |
| 263 |
} elseif ( in_array( $code, array( 'openstation_users_email_exists', 'existing_user_email', 'openstation_users_email_invalid' ), true ) ) { |
| 264 |
$field = 'email'; |
| 265 |
} elseif ( 'openstation_users_role_forbidden' === $code ) { |
| 266 |
$field = 'role'; |
| 267 |
} |
| 268 |
$state->set( 'createError', $result->get_error_message() ); |
| 269 |
$state->set( 'createField', $field ); |
| 270 |
$os->toast( $result->get_error_message() ); |
| 271 |
return; |
| 272 |
} |
| 273 |
// translators: %s is the user's email address. |
| 274 |
$os->toast( sprintf( __( 'User created — welcome email sent to %s.', 'desktop-mode' ), $result['email'] ) ); |
| 275 |
$os->announce( 'user', 'created', array( (int) $result['user_id'] ) ); |
| 276 |
// Back to the list, first page, so the new user shows up. |
| 277 |
$state->set( 'tab', 'all' ); |
| 278 |
$state->set( 'page', 1 ); |
| 279 |
$state->set( 'created', (int) $state->get( 'created' ) + 1 ); |
| 280 |
} |
| 281 |
) |
| 282 |
// A profile saved in the User Edit window, a role changed here, a |
| 283 |
// user created anywhere: the list repaints (the app's own announces |
| 284 |
// are skipped by the runtime — the action already returned the rows). |
| 285 |
->watch( 'user' ) |
| 286 |
->data( |
| 287 |
static function ( State $state ) { |
| 288 |
$list = openstation_app_rest_page( 'wp/v2/users', list_query( $state ) ); |
| 289 |
// Page out of range — the user was on page 7 and changed the |
| 290 |
// page size. Land on page 1 rather than paint an empty table. |
| 291 |
if ( $state->get( 'page' ) > 1 && openstation_app_rest_page_is_out_of_range( $list ) ) { |
| 292 |
$state->set( 'page', 1 ); |
| 293 |
$list = openstation_app_rest_page( 'wp/v2/users', list_query( $state ) ); |
| 294 |
} |
| 295 |
// The Content column: the page's counts in two grouped |
| 296 |
// queries, merged in under the name the REST field uses. |
| 297 |
$stats = openstation_users_window_stats_for( wp_list_pluck( $list['items'], 'id' ) ); |
| 298 |
foreach ( $list['items'] as $i => $row ) { |
| 299 |
$id = isset( $row['id'] ) ? (int) $row['id'] : 0; |
| 300 |
if ( isset( $stats[ $id ] ) ) { |
| 301 |
$list['items'][ $i ]['openstation_user_stats'] = $stats[ $id ]; |
| 302 |
} |
| 303 |
} |
| 304 |
return array( 'list' => $list ); |
| 305 |
} |
| 306 |
); |
| 307 |
|