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