xref: /plugin/usersettings/admin.php (revision 49b74e0a20d271d13e295d5f68707f57e70072a5)
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'])) : '&mdash;') . '</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, '&amp;');
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, '&amp;');
467    }
468}
469