row field used for that sort */ protected $sortFields = [ 'login' => 'user', 'name' => 'display_name', 'mail' => 'mail', 'grps' => 'grps', 'setting' => 'setting_label', 'value' => 'value_display', 'changedby' => 'changed_by_display', 'changedat' => 'changed_at', ]; // --------------------------------------------------------------------- // Admin plugin metadata // --------------------------------------------------------------------- /** Only administrators may see other users' preferences. */ public function forAdminOnly() { return true; } /** Position in the admin menu. */ public function getMenuSort() { return 1000; } /** Admin menu label — distinct from the user menu's "Preferences". */ public function getMenuText($language) { return $this->getLang('admin_menu'); } // --------------------------------------------------------------------- // Component access // --------------------------------------------------------------------- /** @return helper_plugin_usersettings|null */ protected function getHelper() { return plugin_load('helper', 'usersettings'); } /** @return action_plugin_usersettings|null */ protected function getActionPlugin() { return plugin_load('action', 'usersettings'); } // --------------------------------------------------------------------- // Request handling // --------------------------------------------------------------------- /** * Handle a submitted per-user edit form. * * Runs only for admins (DokuWiki's admin dispatcher enforces * forAdminOnly() before this is called). Uses Post/Redirect/Get. */ public function handle() { global $INPUT, $ID; if (!$INPUT->post->bool('usersettings_adminsave')) { return; } if (!checkSecurityToken()) { return; } $this->processAdminSave(); // Post/Redirect/Get back to the overview send_redirect(wl($ID, ['do' => 'admin', 'page' => 'usersettings'], true, '&')); } /** * Validate the target user and store the submitted preferences for them, * recording the acting admin as the actor. Kept redirect-free so it can * be tested directly. * * @return bool */ public function processAdminSave() { global $INPUT, $auth; $target = $INPUT->post->str('edituser'); $admin = $INPUT->server->str('REMOTE_USER'); $userData = ($auth !== null) ? $auth->getUserData($target) : false; if ($userData === false) { msg($this->getLang('badidentuser'), -1); return false; } $action = $this->getActionPlugin(); $ok = ($action !== null) && $action->saveSubmittedPreferences($target, $admin); $name = (($userData['name'] ?? '') !== '') ? $userData['name'] : $target; msg( sprintf($this->getLang($ok ? 'adminsaved' : 'adminsavefail'), hsc($name)), $ok ? 1 : -1 ); return $ok; } // --------------------------------------------------------------------- // Output // --------------------------------------------------------------------- /** * Render either the overview table or, when an edituser parameter is * present, the per-user edit form. */ public function html() { global $INPUT; $edituser = $INPUT->get->str('edituser'); if ($edituser !== '') { echo $this->renderEditForm($edituser); } else { echo $this->renderTable(); } } // ---- overview table -------------------------------------------------- /** * Build the rows of the overview: one per (user x toggle). * * @param array $users [username => userdata] as from $auth->retrieveUsers() * @param array $toggles registered toggle definitions * @return array list of row arrays */ public function buildRows(array $users, array $toggles) { $helper = $this->getHelper(); if ($helper === null) { return []; } $rows = []; foreach ($users as $username => $userData) { $displayName = (!empty($userData['name'])) ? $userData['name'] : $username; $mail = $userData['mail'] ?? ''; $grps = isset($userData['grps']) ? implode(', ', (array) $userData['grps']) : ''; $stored = $helper->loadUserData($username); foreach ($toggles as $key => $def) { if (isset($stored[$key]) && array_key_exists('value', $stored[$key])) { $value = $stored[$key]['value']; $changedBy = $stored[$key]['changed_by'] ?? ''; $changedAt = (int) ($stored[$key]['changed_at'] ?? 0); $isDefault = false; } else { $value = $def['default']; $changedBy = ''; $changedAt = 0; $isDefault = true; } $rows[] = [ 'user' => $username, 'display_name' => $displayName, 'mail' => $mail, 'grps' => $grps, 'setting_key' => $key, 'setting_label' => $def['label'], 'value_display' => $this->displayValue($def, $value), 'is_default' => $isDefault, 'changed_by_display' => $isDefault ? '' : $this->resolveActor($changedBy, $users), 'changed_at' => $changedAt, ]; } } return $rows; } /** * Sort overview rows by the given column and direction. * * @param array $rows * @param string $sort one of the keys of $this->sortFields * @param string $dir 'asc' or 'desc' * @return array */ public function sortRows(array $rows, $sort, $dir) { $field = $this->sortFields[$sort] ?? 'display_name'; usort($rows, function ($a, $b) use ($field) { if ($field === 'changed_at') { return $a[$field] <=> $b[$field]; } return strcasecmp((string) $a[$field], (string) $b[$field]); }); if ($dir === 'desc') { $rows = array_reverse($rows); } return $rows; } /** * Human-readable value of a toggle: On/Off for a checkbox, the option * label for a select. * * @param array $def * @param mixed $value * @return string */ public function displayValue(array $def, $value) { if ($def['type'] === 'select') { if (isset($def['options'][$value])) { return (string) $def['options'][$value]; } return (string) $value; // stored value no longer a defined option } return $this->getLang(empty($value) ? 'val_off' : 'val_on'); } /** * Resolve an actor username to a display name, falling back to the raw * username when the actor is not (or no longer) a known user. * * @param string $actor * @param array $users [username => userdata] * @return string */ protected function resolveActor($actor, array $users) { if ($actor === '') { return ''; } if (isset($users[$actor]) && !empty($users[$actor]['name'])) { return $users[$actor]['name']; } return $actor; } /** * Render the overview table: sortable headers, a per-column text-filter * row (plus the existing setting drop-down), the rows for the current * page, and a numbered pager. * * @return string */ protected function renderTable() { global $INPUT, $auth, $ID; $helper = $this->getHelper(); $toggles = $helper ? $helper->getRegisteredToggles() : []; $html = '
'; $html .= '

' . hsc($this->getLang('admin_heading')) . '

'; if (empty($toggles)) { return $html . '

' . hsc($this->getLang('notoggles')) . '

'; } $users = ($auth !== null) ? $auth->retrieveUsers(0, 0) : []; if (empty($users)) { return $html . '

' . hsc($this->getLang('nousers')) . '

'; } $showMail = (bool) $this->getConf('show_mail'); $showGrps = (bool) $this->getConf('show_grps'); $perPage = (int) $this->getConf('entries_per_page'); // visible columns, in display order $cols = ['login', 'name']; if ($showMail) { $cols[] = 'mail'; } if ($showGrps) { $cols[] = 'grps'; } $cols = array_merge($cols, ['setting', 'value', 'changedby', 'changedat']); // col => row field for the text-filterable columns ("Setting" has its // own drop-down; "Changed at" is not filterable) $filterMap = $this->filterFieldMap($cols); // request parameters (sort links and the filter form are GET) $sort = $INPUT->get->str('sort', 'name'); if (!isset($this->sortFields[$sort])) { $sort = 'name'; } if (($sort === 'mail' && !$showMail) || ($sort === 'grps' && !$showGrps)) { $sort = 'name'; } $dir = ($INPUT->get->str('dir') === 'desc') ? 'desc' : 'asc'; $setFilter = $INPUT->get->str('filter'); if ($setFilter !== '' && !isset($toggles[$setFilter])) { $setFilter = ''; } $qfilters = $this->activeFilters(array_keys($filterMap)); $html .= '

' . hsc($this->getLang('admin_intro')) . '

'; // build rows, narrow by the setting drop-down, then the text filters, // then sort — all before paging so the counts and page numbers match $rows = $this->buildRows($users, $toggles); if ($setFilter !== '') { $rows = array_values(array_filter($rows, static function ($r) use ($setFilter) { return $r['setting_key'] === $setFilter; })); } $rows = $this->applyFilters($rows, $qfilters, $filterMap); $rows = $this->sortRows($rows, $sort, $dir); $total = count($rows); [$pageRows, $page, $totalPages, $from, $to] = $this->paginate($rows, $perPage); $labels = [ 'login' => $this->getLang('th_login'), 'name' => $this->getLang('th_name'), 'mail' => $this->getLang('th_mail'), 'grps' => $this->getLang('th_grps'), 'setting' => $this->getLang('th_setting'), 'value' => $this->getLang('th_value'), 'changedby' => $this->getLang('th_changedby'), 'changedat' => $this->getLang('th_changedat'), ]; // GET form so the filters combine with the sort links and bookmark // cleanly; the action URL's query string is dropped on submit, so the // standing parameters travel as hidden fields. $html .= '
'; $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; // a new search lands on page 1 $html .= '
'; $html .= ''; $html .= ''; foreach ($cols as $c) { $html .= $this->sortHeader($labels[$c], $c, $sort, $dir, $setFilter, $qfilters); } $html .= ''; $html .= $this->renderFilterRow($cols, $filterMap, $qfilters, $toggles, $setFilter, $sort, $dir); $html .= ''; if ($total === 0) { $html .= ''; } else { foreach ($pageRows as $row) { $rowClass = $row['is_default'] ? ' class="us-default-row"' : ''; $html .= ''; foreach ($cols as $c) { $html .= $this->bodyCell($c, $row); } $html .= ''; } } $html .= '
' . hsc($this->getLang('none')) . '
'; $html .= '
'; $html .= $this->renderPager($page, $totalPages, $sort, $dir, $setFilter, $qfilters); if ($total > 0) { $html .= '

' . hsc(sprintf($this->getLang('shown'), $from, $to, $total)) . '

'; } return $html . ''; } /** * Render one body cell for a column. * * @param string $c column key * @param array $row * @return string */ protected function bodyCell($c, array $row) { switch ($c) { case 'login': return '' . hsc($row['user']) . ''; case 'name': $editUrl = $this->pageURL(['edituser' => $row['user']]); return '' . hsc($row['display_name']) . ''; case 'mail': return '' . hsc($row['mail']) . ''; case 'grps': return '' . hsc($row['grps']) . ''; case 'setting': return '' . hsc($row['setting_label']) . ''; case 'value': return '' . hsc($row['value_display']) . ''; case 'changedby': return '' . ($row['is_default'] ? '' . hsc($this->getLang('bydefault')) . '' : hsc($row['changed_by_display'])) . ''; case 'changedat': return '' . ($row['changed_at'] > 0 ? hsc(dformat($row['changed_at'])) : '—') . ''; default: return ''; } } // ---- filtering ------------------------------------------------------- /** * Map of text-filterable column => row field, limited to the visible * columns. "Setting" (drop-down) and "Changed at" are deliberately absent. * * @param string[] $cols visible columns * @return array */ protected function filterFieldMap(array $cols) { $all = [ 'login' => 'user', 'name' => 'display_name', 'mail' => 'mail', 'grps' => 'grps', 'value' => 'value_display', 'changedby' => 'changed_by_display', ]; $map = []; foreach ($cols as $c) { if (isset($all[$c])) { $map[$c] = $all[$c]; } } return $map; } /** * Read the active text filters from the request (the q[] array), keeping * only the given columns and dropping blanks. * * @param string[] $cols filterable column keys * @return array [column => trimmed term] */ protected function activeFilters(array $cols) { global $INPUT; $raw = $INPUT->arr('q'); $out = []; foreach ($cols as $c) { if (isset($raw[$c]) && is_string($raw[$c])) { $term = trim($raw[$c]); if ($term !== '') { $out[$c] = $term; } } } return $out; } /** * Keep only rows matching every active text filter (substring, * case-insensitive). * * @param array $rows * @param array $qfilters [column => term] * @param array $map [column => row field] * @return array */ protected function applyFilters(array $rows, array $qfilters, array $map) { if ($qfilters === []) { return $rows; } return array_values(array_filter($rows, function ($row) use ($qfilters, $map) { foreach ($qfilters as $col => $term) { $field = $map[$col] ?? null; if ($field === null) { continue; } if (!$this->matches($row[$field] ?? '', $term)) { return false; } } return true; })); } /** * Case-insensitive UTF-8 substring test. * * @param string $haystack * @param string $needle * @return bool */ protected function matches($haystack, $needle) { if ($needle === '') { return true; } $h = PhpString::strtolower((string) $haystack); $n = PhpString::strtolower((string) $needle); return PhpString::strpos($h, $n) !== false; } // ---- pagination ------------------------------------------------------ /** * Slice the rows for the current page. * * @param array $rows filtered + sorted rows * @param int $perPage rows per page; <= 0 means "all on one page" * @return array [pageRows, page, totalPages, from, to] — from/to are 1-based * row numbers of the slice (0 when there are no rows) */ protected function paginate(array $rows, $perPage) { global $INPUT; $total = count($rows); if ($perPage <= 0) { return [$rows, 1, 1, $total > 0 ? 1 : 0, $total]; } $totalPages = max(1, (int) ceil($total / $perPage)); $page = $INPUT->int('pg', 1); if ($page < 1) { $page = 1; } if ($page > $totalPages) { $page = $totalPages; } $offset = ($page - 1) * $perPage; $slice = array_slice($rows, $offset, $perPage); $from = $total > 0 ? $offset + 1 : 0; $to = min($total, $offset + $perPage); return [$slice, $page, $totalPages, $from, $to]; } // ---- link + header + filter-row + pager helpers ---------------------- /** * The standing query parameters every in-table link must carry: sort, * direction, the setting drop-down and the active text filters. * * @param string $sort * @param string $dir * @param string $setFilter * @param array $qfilters * @return array */ protected function standingParams($sort, $dir, $setFilter, array $qfilters) { $params = ['sort' => $sort, 'dir' => $dir]; if ($setFilter !== '') { $params['filter'] = $setFilter; } if ($qfilters !== []) { $params['q'] = $qfilters; } return $params; } /** * Build a URL back to this admin page with the given query parameters. * * @param array $params * @return string HTML-attribute-safe URL */ protected function tableURL(array $params) { global $ID; $base = ['do' => 'admin', 'page' => 'usersettings']; return wl($ID, array_merge($base, $params), false, '&'); } /** * Render one sortable column header. Clicking the active column flips the * direction; the drop-down filter and text filters are preserved and the * page resets to 1. * * @param string $label * @param string $col sort key for this column * @param string $sort currently active sort key * @param string $dir currently active direction * @param string $setFilter currently selected setting key * @param array $qfilters active text filters * @return string */ protected function sortHeader($label, $col, $sort, $dir, $setFilter, array $qfilters) { // clicking the active column flips direction; others start ascending $newDir = ($sort === $col && $dir === 'asc') ? 'desc' : 'asc'; $arrow = ''; if ($sort === $col) { $arrow = ($dir === 'asc') ? " \u{25B2}" : " \u{25BC}"; } $url = $this->tableURL(array_merge( $this->standingParams($sort, $dir, $setFilter, $qfilters), ['sort' => $col, 'dir' => $newDir] )); return '' . hsc($label) . $arrow . ''; } /** * The per-column filter row: a text input under each text-filterable * column, the existing setting drop-down under "Setting", and the * Search/Clear controls under the (non-filterable) "Changed at" column. * * @param string[] $cols visible columns in order * @param array $filterMap col => row field for text-filterable columns * @param array $qfilters active text filters * @param array $toggles registered toggles (for the drop-down) * @param string $setFilter currently selected setting key * @param string $sort * @param string $dir * @return string */ protected function renderFilterRow(array $cols, array $filterMap, array $qfilters, array $toggles, $setFilter, $sort, $dir) { $html = ''; foreach ($cols as $c) { if ($c === 'setting') { $html .= ''; } elseif ($c === 'changedat') { $html .= ''; $html .= ''; if ($setFilter !== '' || $qfilters !== []) { $clear = $this->tableURL(['sort' => $sort, 'dir' => $dir]); $html .= ' ' . hsc($this->getLang('filter_clear')) . ''; } $html .= ''; } elseif (isset($filterMap[$c])) { $val = isset($qfilters[$c]) ? hsc($qfilters[$c]) : ''; $html .= ''; } else { $html .= ''; } } return $html . ''; } /** * Render the numbered pager: « prev 1 … 4 [5] 6 … 20 next ». Returns the * empty string when there is only one page. * * @param int $page * @param int $totalPages * @param string $sort * @param string $dir * @param string $setFilter * @param array $qfilters * @return string */ protected function renderPager($page, $totalPages, $sort, $dir, $setFilter, array $qfilters) { if ($totalPages <= 1) { return ''; } $html = ''; } /** * One pager link (number or arrow), preserving sort + filters. * * @param int $p target page * @param string $sort * @param string $dir * @param string $setFilter * @param array $qfilters * @param string $text already-safe link text (number or entity) * @param string $titleKey lang key for the title attribute, or '' for none * @return string */ protected function pagerLink($p, $sort, $dir, $setFilter, array $qfilters, $text, $titleKey) { $url = $this->tableURL(array_merge( $this->standingParams($sort, $dir, $setFilter, $qfilters), ['pg' => $p] )); $title = ($titleKey !== '') ? ' title="' . hsc($this->getLang($titleKey)) . '"' : ''; return '' . $text . ''; } /** * Page numbers around the current page, 0 marking an elided gap; always * includes the first and last page. * * @param int $page * @param int $totalPages * @return int[] */ protected function pageWindow($page, $totalPages) { $window = 2; $keep = []; for ($i = 1; $i <= $totalPages; $i++) { if ($i === 1 || $i === $totalPages || ($i >= $page - $window && $i <= $page + $window)) { $keep[] = $i; } } $out = []; $prev = 0; foreach ($keep as $p) { if ($prev && ($p - $prev) > 1) { $out[] = 0; // gap marker } $out[] = $p; $prev = $p; } return $out; } // ---- per-user edit form --------------------------------------------- /** * Render the edit form for one user's preferences. * * @param string $user * @return string */ protected function renderEditForm($user) { global $auth, $ID; $html = '
'; $userData = ($auth !== null) ? $auth->getUserData($user) : false; if ($userData === false) { $html .= '

' . hsc($this->getLang('admin_heading')) . '

'; $html .= '

' . hsc($this->getLang('badidentuser')) . '

'; $html .= '

' . hsc($this->getLang('edit_back')) . '

'; return $html . '
'; } $displayName = (($userData['name'] ?? '') !== '') ? $userData['name'] : $user; $html .= '

' . hsc(sprintf($this->getLang('edit_heading'), $displayName)) . '

'; $helper = $this->getHelper(); $action = $this->getActionPlugin(); $toggles = $helper ? $helper->getRegisteredToggles() : []; if (empty($toggles) || $action === null) { $html .= '

' . hsc($this->getLang('notoggles')) . '

'; $html .= '

' . hsc($this->getLang('edit_back')) . '

'; return $html . ''; } $formAction = wl($ID, ['do' => 'admin', 'page' => 'usersettings'], false, '&'); $html .= '
'; $html .= formSecurityToken(false); $html .= ''; $html .= ''; foreach ($toggles as $key => $def) { $html .= $action->renderToggleRow($def, $helper->getPreference($key, $user)); } $html .= '
'; $html .= ' '; $html .= '' . hsc($this->getLang('edit_back')) . ''; $html .= '
'; $html .= '
'; return $html . ''; } // ---- helpers --------------------------------------------------------- /** * Build a URL back to this admin page with the given extra parameters. * * @param array $params * @return string HTML-attribute-safe URL */ protected function pageURL(array $params = []) { global $ID; $base = ['do' => 'admin', 'page' => 'usersettings']; return wl($ID, array_merge($base, $params), false, '&'); } }