1<?php 2 3/** 4 * User Settings plugin — admin component. 5 * 6 * Provides an admin-only overview of every user's preferences as a flat, 7 * sortable table: one row per (user x setting), with the columns 8 * Display name | Setting | Value | Changed by | Changed at. 9 * A row's value is the user's explicit choice, or the toggle's default 10 * (marked as such) when they never set one. A filter narrows the table to a 11 * single setting; the table never grows wider as more toggles are added. 12 * 13 * Clicking a display name opens a per-user edit form (Model A+: an admin may 14 * change anyone's preferences). Such a change is stored with the admin as the 15 * recorded actor, and the user can still change it back themselves later. 16 */ 17 18// must be run within DokuWiki 19if (!defined('DOKU_INC')) die(); 20 21use dokuwiki\Extension\AdminPlugin; 22 23class admin_plugin_usersettings extends AdminPlugin 24{ 25 /** @var string[] sort key => row field used for that sort */ 26 protected $sortFields = [ 27 'name' => 'display_name', 28 'setting' => 'setting_label', 29 'value' => 'value_display', 30 'changedby' => 'changed_by_display', 31 'changedat' => 'changed_at', 32 ]; 33 34 // --------------------------------------------------------------------- 35 // Admin plugin metadata 36 // --------------------------------------------------------------------- 37 38 /** Only administrators may see other users' preferences. */ 39 public function forAdminOnly() 40 { 41 return true; 42 } 43 44 /** Position in the admin menu. */ 45 public function getMenuSort() 46 { 47 return 1000; 48 } 49 50 /** Admin menu label — distinct from the user menu's "Preferences". */ 51 public function getMenuText($language) 52 { 53 return $this->getLang('admin_menu'); 54 } 55 56 // --------------------------------------------------------------------- 57 // Component access 58 // --------------------------------------------------------------------- 59 60 /** @return helper_plugin_usersettings|null */ 61 protected function getHelper() 62 { 63 return plugin_load('helper', 'usersettings'); 64 } 65 66 /** @return action_plugin_usersettings|null */ 67 protected function getActionPlugin() 68 { 69 return plugin_load('action', 'usersettings'); 70 } 71 72 // --------------------------------------------------------------------- 73 // Request handling 74 // --------------------------------------------------------------------- 75 76 /** 77 * Handle a submitted per-user edit form. 78 * 79 * Runs only for admins (DokuWiki's admin dispatcher enforces 80 * forAdminOnly() before this is called). Uses Post/Redirect/Get. 81 */ 82 public function handle() 83 { 84 global $INPUT, $ID; 85 86 if (!$INPUT->post->bool('usersettings_adminsave')) { 87 return; 88 } 89 if (!checkSecurityToken()) { 90 return; 91 } 92 93 $this->processAdminSave(); 94 95 // Post/Redirect/Get back to the overview 96 send_redirect(wl($ID, ['do' => 'admin', 'page' => 'usersettings'], true, '&')); 97 } 98 99 /** 100 * Validate the target user and store the submitted preferences for them, 101 * recording the acting admin as the actor. Kept redirect-free so it can 102 * be tested directly. 103 * 104 * @return bool 105 */ 106 public function processAdminSave() 107 { 108 global $INPUT, $auth; 109 110 $target = $INPUT->post->str('edituser'); 111 $admin = $INPUT->server->str('REMOTE_USER'); 112 113 $userData = ($auth !== null) ? $auth->getUserData($target) : false; 114 if ($userData === false) { 115 msg($this->getLang('badidentuser'), -1); 116 return false; 117 } 118 119 $action = $this->getActionPlugin(); 120 $ok = ($action !== null) && $action->saveSubmittedPreferences($target, $admin); 121 122 $name = (($userData['name'] ?? '') !== '') ? $userData['name'] : $target; 123 msg( 124 sprintf($this->getLang($ok ? 'adminsaved' : 'adminsavefail'), hsc($name)), 125 $ok ? 1 : -1 126 ); 127 return $ok; 128 } 129 130 // --------------------------------------------------------------------- 131 // Output 132 // --------------------------------------------------------------------- 133 134 /** 135 * Render either the overview table or, when an edituser parameter is 136 * present, the per-user edit form. 137 */ 138 public function html() 139 { 140 global $INPUT; 141 142 $edituser = $INPUT->get->str('edituser'); 143 if ($edituser !== '') { 144 echo $this->renderEditForm($edituser); 145 } else { 146 echo $this->renderTable(); 147 } 148 } 149 150 // ---- overview table -------------------------------------------------- 151 152 /** 153 * Build the rows of the overview: one per (user x toggle). 154 * 155 * @param array $users [username => userdata] as from $auth->retrieveUsers() 156 * @param array $toggles registered toggle definitions 157 * @return array list of row arrays 158 */ 159 public function buildRows(array $users, array $toggles) 160 { 161 $helper = $this->getHelper(); 162 if ($helper === null) { 163 return []; 164 } 165 166 $rows = []; 167 foreach ($users as $username => $userData) { 168 $displayName = (!empty($userData['name'])) ? $userData['name'] : $username; 169 $stored = $helper->loadUserData($username); 170 171 foreach ($toggles as $key => $def) { 172 if (isset($stored[$key]) && array_key_exists('value', $stored[$key])) { 173 $value = $stored[$key]['value']; 174 $changedBy = $stored[$key]['changed_by'] ?? ''; 175 $changedAt = (int) ($stored[$key]['changed_at'] ?? 0); 176 $isDefault = false; 177 } else { 178 $value = $def['default']; 179 $changedBy = ''; 180 $changedAt = 0; 181 $isDefault = true; 182 } 183 184 $rows[] = [ 185 'user' => $username, 186 'display_name' => $displayName, 187 'setting_key' => $key, 188 'setting_label' => $def['label'], 189 'value_display' => $this->displayValue($def, $value), 190 'is_default' => $isDefault, 191 'changed_by_display' => $isDefault ? '' : $this->resolveActor($changedBy, $users), 192 'changed_at' => $changedAt, 193 ]; 194 } 195 } 196 return $rows; 197 } 198 199 /** 200 * Sort overview rows by the given column and direction. 201 * 202 * @param array $rows 203 * @param string $sort one of the keys of $this->sortFields 204 * @param string $dir 'asc' or 'desc' 205 * @return array 206 */ 207 public function sortRows(array $rows, $sort, $dir) 208 { 209 $field = $this->sortFields[$sort] ?? 'display_name'; 210 211 usort($rows, function ($a, $b) use ($field) { 212 if ($field === 'changed_at') { 213 return $a[$field] <=> $b[$field]; 214 } 215 return strcasecmp((string) $a[$field], (string) $b[$field]); 216 }); 217 218 if ($dir === 'desc') { 219 $rows = array_reverse($rows); 220 } 221 return $rows; 222 } 223 224 /** 225 * Human-readable value of a toggle: On/Off for a checkbox, the option 226 * label for a select. 227 * 228 * @param array $def 229 * @param mixed $value 230 * @return string 231 */ 232 public function displayValue(array $def, $value) 233 { 234 if ($def['type'] === 'select') { 235 if (isset($def['options'][$value])) { 236 return (string) $def['options'][$value]; 237 } 238 return (string) $value; // stored value no longer a defined option 239 } 240 return $this->getLang(empty($value) ? 'val_off' : 'val_on'); 241 } 242 243 /** 244 * Resolve an actor username to a display name, falling back to the raw 245 * username when the actor is not (or no longer) a known user. 246 * 247 * @param string $actor 248 * @param array $users [username => userdata] 249 * @return string 250 */ 251 protected function resolveActor($actor, array $users) 252 { 253 if ($actor === '') { 254 return ''; 255 } 256 if (isset($users[$actor]) && !empty($users[$actor]['name'])) { 257 return $users[$actor]['name']; 258 } 259 return $actor; 260 } 261 262 /** 263 * Render the overview table. 264 * 265 * @return string 266 */ 267 protected function renderTable() 268 { 269 global $INPUT, $auth; 270 271 $helper = $this->getHelper(); 272 $toggles = $helper ? $helper->getRegisteredToggles() : []; 273 274 $html = '<div class="plugin_usersettings_admin">'; 275 $html .= '<h1>' . hsc($this->getLang('admin_heading')) . '</h1>'; 276 277 if (empty($toggles)) { 278 return $html . '<p>' . hsc($this->getLang('notoggles')) . '</p></div>'; 279 } 280 281 $users = ($auth !== null) ? $auth->retrieveUsers(0, 0) : []; 282 if (empty($users)) { 283 return $html . '<p>' . hsc($this->getLang('nousers')) . '</p></div>'; 284 } 285 286 // request parameters (sort links and the filter form are GET) 287 $sort = $INPUT->get->str('sort', 'name'); 288 $dir = ($INPUT->get->str('dir') === 'desc') ? 'desc' : 'asc'; 289 $filter = $INPUT->get->str('filter'); 290 if (!isset($this->sortFields[$sort])) { 291 $sort = 'name'; 292 } 293 if ($filter !== '' && !isset($toggles[$filter])) { 294 $filter = ''; 295 } 296 297 $html .= '<p>' . hsc($this->getLang('admin_intro')) . '</p>'; 298 $html .= $this->renderFilter($toggles, $sort, $dir, $filter); 299 300 // rows 301 $rows = $this->buildRows($users, $toggles); 302 if ($filter !== '') { 303 $rows = array_values(array_filter($rows, function ($r) use ($filter) { 304 return $r['setting_key'] === $filter; 305 })); 306 } 307 $rows = $this->sortRows($rows, $sort, $dir); 308 309 // table 310 $html .= '<table class="inline plugin_usersettings_table">'; 311 $html .= '<thead><tr>'; 312 $html .= $this->sortHeader($this->getLang('th_name'), 'name', $sort, $dir, $filter); 313 $html .= $this->sortHeader($this->getLang('th_setting'), 'setting', $sort, $dir, $filter); 314 $html .= $this->sortHeader($this->getLang('th_value'), 'value', $sort, $dir, $filter); 315 $html .= $this->sortHeader($this->getLang('th_changedby'), 'changedby', $sort, $dir, $filter); 316 $html .= $this->sortHeader($this->getLang('th_changedat'), 'changedat', $sort, $dir, $filter); 317 $html .= '</tr></thead><tbody>'; 318 319 foreach ($rows as $row) { 320 $rowClass = $row['is_default'] ? ' class="us-default-row"' : ''; 321 $editUrl = $this->pageURL(['edituser' => $row['user']]); 322 323 $html .= '<tr' . $rowClass . '>'; 324 $html .= '<td><a href="' . $editUrl . '">' . hsc($row['display_name']) . '</a></td>'; 325 $html .= '<td>' . hsc($row['setting_label']) . '</td>'; 326 $html .= '<td>' . hsc($row['value_display']) . '</td>'; 327 $html .= '<td>' . ($row['is_default'] 328 ? '<span class="us-default-mark">' . hsc($this->getLang('bydefault')) . '</span>' 329 : hsc($row['changed_by_display'])) . '</td>'; 330 $html .= '<td>' . ($row['changed_at'] > 0 ? hsc(dformat($row['changed_at'])) : '—') . '</td>'; 331 $html .= '</tr>'; 332 } 333 334 $html .= '</tbody></table>'; 335 return $html . '</div>'; 336 } 337 338 /** 339 * Render the setting filter (a small GET form). 340 * 341 * @param array $toggles 342 * @param string $sort 343 * @param string $dir 344 * @param string $filter currently selected setting key 345 * @return string 346 */ 347 protected function renderFilter(array $toggles, $sort, $dir, $filter) 348 { 349 global $ID; 350 351 // GET forms drop the action URL's query string, so every parameter 352 // travels as an explicit field — this also survives URL rewriting. 353 $html = '<form class="us-filter" method="get" action="' . DOKU_BASE . DOKU_SCRIPT . '">'; 354 $html .= '<input type="hidden" name="id" value="' . hsc($ID) . '" />'; 355 $html .= '<input type="hidden" name="do" value="admin" />'; 356 $html .= '<input type="hidden" name="page" value="usersettings" />'; 357 $html .= '<input type="hidden" name="sort" value="' . hsc($sort) . '" />'; 358 $html .= '<input type="hidden" name="dir" value="' . hsc($dir) . '" />'; 359 360 $html .= '<label>' . hsc($this->getLang('filter_label')) . ' '; 361 $html .= '<select name="filter">'; 362 $html .= '<option value="">' . hsc($this->getLang('filter_all')) . '</option>'; 363 foreach ($toggles as $key => $def) { 364 $selected = ($filter === $key) ? ' selected="selected"' : ''; 365 $html .= '<option value="' . hsc($key) . '"' . $selected . '>' 366 . hsc($def['label']) . '</option>'; 367 } 368 $html .= '</select></label> '; 369 $html .= '<button type="submit">' . hsc($this->getLang('filter_apply')) . '</button>'; 370 return $html . '</form>'; 371 } 372 373 /** 374 * Render one sortable column header. 375 * 376 * @param string $label 377 * @param string $col sort key for this column 378 * @param string $sort currently active sort key 379 * @param string $dir currently active direction 380 * @param string $filter currently active filter (preserved in the link) 381 * @return string 382 */ 383 protected function sortHeader($label, $col, $sort, $dir, $filter) 384 { 385 // clicking the active column flips direction; others start ascending 386 $newDir = ($sort === $col && $dir === 'asc') ? 'desc' : 'asc'; 387 $arrow = ''; 388 if ($sort === $col) { 389 $arrow = ($dir === 'asc') ? " \u{25B2}" : " \u{25BC}"; 390 } 391 392 $url = $this->pageURL(['sort' => $col, 'dir' => $newDir, 'filter' => $filter]); 393 return '<th><a href="' . $url . '">' . hsc($label) . $arrow . '</a></th>'; 394 } 395 396 // ---- per-user edit form --------------------------------------------- 397 398 /** 399 * Render the edit form for one user's preferences. 400 * 401 * @param string $user 402 * @return string 403 */ 404 protected function renderEditForm($user) 405 { 406 global $auth, $ID; 407 408 $html = '<div class="plugin_usersettings_admin plugin_usersettings">'; 409 410 $userData = ($auth !== null) ? $auth->getUserData($user) : false; 411 if ($userData === false) { 412 $html .= '<h1>' . hsc($this->getLang('admin_heading')) . '</h1>'; 413 $html .= '<p>' . hsc($this->getLang('badidentuser')) . '</p>'; 414 $html .= '<p><a href="' . $this->pageURL() . '">' 415 . hsc($this->getLang('edit_back')) . '</a></p>'; 416 return $html . '</div>'; 417 } 418 419 $displayName = (($userData['name'] ?? '') !== '') ? $userData['name'] : $user; 420 $html .= '<h1>' . hsc(sprintf($this->getLang('edit_heading'), $displayName)) . '</h1>'; 421 422 $helper = $this->getHelper(); 423 $action = $this->getActionPlugin(); 424 $toggles = $helper ? $helper->getRegisteredToggles() : []; 425 426 if (empty($toggles) || $action === null) { 427 $html .= '<p>' . hsc($this->getLang('notoggles')) . '</p>'; 428 $html .= '<p><a href="' . $this->pageURL() . '">' 429 . hsc($this->getLang('edit_back')) . '</a></p>'; 430 return $html . '</div>'; 431 } 432 433 $formAction = wl($ID, ['do' => 'admin', 'page' => 'usersettings'], false, '&'); 434 $html .= '<form method="post" action="' . $formAction . '" class="us-form">'; 435 $html .= formSecurityToken(false); 436 $html .= '<input type="hidden" name="edituser" value="' . hsc($user) . '" />'; 437 $html .= '<input type="hidden" name="usersettings_adminsave" value="1" />'; 438 439 foreach ($toggles as $key => $def) { 440 $html .= $action->renderToggleRow($def, $helper->getPreference($key, $user)); 441 } 442 443 $html .= '<div class="us-actions">'; 444 $html .= '<button type="submit" class="button">' 445 . hsc($this->getLang('save')) . '</button> '; 446 $html .= '<a href="' . $this->pageURL() . '" class="us-back">' 447 . hsc($this->getLang('edit_back')) . '</a>'; 448 $html .= '</div>'; 449 $html .= '</form>'; 450 451 return $html . '</div>'; 452 } 453 454 // ---- helpers --------------------------------------------------------- 455 456 /** 457 * Build a URL back to this admin page with the given extra parameters. 458 * 459 * @param array $params 460 * @return string HTML-attribute-safe URL 461 */ 462 protected function pageURL(array $params = []) 463 { 464 global $ID; 465 $base = ['do' => 'admin', 'page' => 'usersettings']; 466 return wl($ID, array_merge($base, $params), false, '&'); 467 } 468} 469