xref: /plugin/usersettings/admin.php (revision f51fe07cd1ae2d1dfd14f525b27faf57a788b46f)
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 1000;
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'])) : '&mdash;') . '</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 plugin_usersettings">';
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, '&amp;');
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, '&amp;');
465    }
466}
467