Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.63% covered (success)
96.63%
258 / 267
79.31% covered (warning)
79.31%
23 / 29
CRAP
0.00% covered (danger)
0.00%
0 / 1
Users
96.63% covered (success)
96.63%
258 / 267
79.31% covered (warning)
79.31%
23 / 29
96
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 context
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 index
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
8
 create
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 store
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 edit
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 update
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 profile
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 saveProfile
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 save
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
8
 delete
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
3.01
 form
96.88% covered (success)
96.88%
31 / 32
0.00% covered (danger)
0.00%
0 / 1
9
 submitted
95.00% covered (success)
95.00%
57 / 60
0.00% covered (danger)
0.00%
0 / 1
20
 submittedRoles
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 roleChoices
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 fields
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 editable
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 blank
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
2.00
 keepOwnSession
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 rejected
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 issues
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 listRow
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 typeLinks
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 roleLabel
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 notice
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
5.02
 url
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 isSelf
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 logAccessChanges
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 roleList
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Controller\Panel;
6
7use Celema\Container\Container;
8use Celema\Core\Exception\HttpForbidden;
9use Celema\Core\Exception\HttpNotFound;
10use Celema\Core\Factory\Factory;
11use Celema\Core\Request;
12use Celema\Core\Response;
13use Celema\Sire\Issue;
14use Cosray\Config;
15use Cosray\Context;
16use Cosray\Field\Services;
17use Cosray\Middleware\Permission;
18use Cosray\Panel\FormPatch;
19use Cosray\Panel\System;
20use Cosray\Security\Policy;
21use Cosray\Session;
22use Cosray\Uid;
23use Cosray\User;
24use Cosray\User\Fields;
25use Cosray\User\PasswordPolicy;
26use Cosray\User\Types;
27use Cosray\Users as UserStore;
28use Cosray\Validation\Account;
29use Override;
30use Psr\Log\LoggerInterface as Logger;
31
32/** @phpstan-import-type UserValues from \Cosray\Users */
33final class Users extends Panel
34{
35    protected const string AREA = 'system';
36
37    private const int PAGE_SIZE = 50;
38
39    public function __construct(
40        Config $config,
41        Container $container,
42        Request $request,
43        private readonly UserStore $users,
44        private readonly Types $types,
45        private readonly Policy $policy,
46        private readonly PasswordPolicy $passwords,
47        private readonly Logger $logger,
48    ) {
49        parent::__construct($config, $container, $request);
50    }
51
52    /**
53     * Every screen in the area renders the rail, so its entries ride the
54     * shared context. The profile screen sits outside the area and turns
55     * the rail off in its own data.
56     *
57     * @param array<string, mixed> $data
58     * @return array<string, mixed>
59     */
60    #[Override]
61    protected function context(array $data = []): array
62    {
63        $url = $this->url();
64        $path = $this->request->uri()->getPath();
65
66        return parent::context(array_merge([
67            'systemNav' => [[
68                'label' => __('nav:users'),
69                'url' => $url,
70                'icon' => 'people',
71                'active' => $path === $url || str_starts_with($path, $url . '/'),
72            ]],
73            'rail' => true,
74        ], $data));
75    }
76
77    /** @return array<string, mixed> */
78    #[Permission('edit-users')]
79    public function index(): array
80    {
81        $type = $this->request->param('type', '');
82        $type = is_string($type) && $this->types->has($type) ? $type : null;
83        $search = $this->request->param('q', '');
84        $search = is_string($search) ? trim($search) : '';
85        $offset = max(0, (int) $this->request->param('offset', 0));
86        $total = $this->users->count($type, $search);
87        $users = $this->users->list($type, $search, self::PAGE_SIZE, $offset);
88        $filter = array_filter(
89            ['type' => $type, 'q' => $search],
90            static fn(?string $value): bool => (
91                $value !== null
92                && $value !== ''
93            ),
94        );
95        $page = fn(int $to): string => $this->url('', [...$filter, 'offset' => $to]);
96
97        return $this->context([
98            'rows' => array_map($this->listRow(...), $users),
99            'types' => $this->typeLinks($type, $search),
100            'search' => $search,
101            'type' => $type,
102            'total' => $total,
103            'rangeStart' => $total === 0 ? 0 : $offset + 1,
104            'rangeEnd' => $offset + count($users),
105            'previousUrl' => $offset > 0 ? $page(max(0, $offset - self::PAGE_SIZE)) : null,
106            'nextUrl' => ($offset + self::PAGE_SIZE) < $total ? $page($offset + self::PAGE_SIZE) : null,
107            'listUrl' => $this->url(),
108            'notice' => $this->notice(),
109        ]);
110    }
111
112    /** @return array<string, mixed> */
113    #[Permission('edit-users')]
114    public function create(Context $context, string $type): array
115    {
116        return $this->form($context, $this->blank($type), false);
117    }
118
119    /** @return array<string, mixed>|Response */
120    #[Permission('edit-users')]
121    public function store(Context $context, Factory $factory, string $type): array|Response
122    {
123        [$values, $password, $errors] = $this->submitted($this->fields($context), $this->blank($type), null);
124
125        if ($values === null || $errors !== []) {
126            return $this->rejected($errors);
127        }
128
129        $user = $this->users->create($type, $values, (string) $password, $this->actor());
130        $this->logger->notice('User {login} created with roles {roles} by {actor}', [
131            'login' => $user->loginName(),
132            'roles' => self::roleList($user->roles),
133            'actor' => $this->currentUser()->loginName(),
134        ]);
135
136        return Response::create($factory)->redirect($this->url('/' . $user->uid, ['notice' => 'created']), 303);
137    }
138
139    /** @return array<string, mixed> */
140    #[Permission('edit-users')]
141    public function edit(Context $context, string $uid): array
142    {
143        return $this->form($context, $this->editable($uid), true);
144    }
145
146    /** @return array<string, mixed> */
147    #[Permission('edit-users')]
148    public function update(Context $context, string $uid): array
149    {
150        return $this->save($context, $this->editable($uid), false);
151    }
152
153    /**
154     * Every panel user's own account; it needs no `edit-users`.
155     *
156     * @return array<string, mixed>
157     */
158    public function profile(Context $context): array
159    {
160        return $this->form($context, $this->currentUser(), true, true);
161    }
162
163    /** @return array<string, mixed> */
164    public function saveProfile(Context $context): array
165    {
166        return $this->save($context, $this->currentUser(), true);
167    }
168
169    /** @return array<string, mixed> */
170    private function save(Context $context, User $user, bool $profile): array
171    {
172        [$values, $password, $errors] = $this->submitted($this->fields($context), $user, $user);
173        $current = $this->formData()['current_password'] ?? '';
174
175        if ($profile && $password !== null && !password_verify(is_string($current) ? $current : '', $user->password)) {
176            $errors[] = ['message' => __('user:error-current-password'), 'path' => ['current_password']];
177        }
178
179        if ($values === null || $errors !== []) {
180            return $this->rejected($errors);
181        }
182
183        $this->users->update($user, $values, $this->actor());
184        $this->logAccessChanges($user, $values['roles'], $values['active']);
185
186        if ($password !== null) {
187            $this->users->setPassword($user, $password, $this->actor());
188            $this->keepOwnSession($user);
189            $this->logger->notice('Password of {login} changed by {actor}', [
190                'login' => $user->loginName(),
191                'actor' => $this->currentUser()->loginName(),
192            ]);
193        }
194
195        return ['saved' => true, 'message' => __('editor:saved'), 'errors' => []];
196    }
197
198    /** @return array<string, mixed>|Response */
199    #[Permission('edit-users')]
200    public function delete(Factory $factory, string $uid): array|Response
201    {
202        $user = $this->editable($uid);
203
204        if ($this->isSelf($user)) {
205            return $this->rejected([['message' => __('user:error-delete-self'), 'path' => []]]);
206        }
207
208        if ($this->users->isLastSuperuser($user)) {
209            return $this->rejected([['message' => __('user:error-last-superuser'), 'path' => []]]);
210        }
211
212        $this->users->delete($user, $this->actor());
213        $this->logger->notice('User {login} deleted by {actor}', [
214            'login' => $user->loginName(),
215            'actor' => $this->currentUser()->loginName(),
216        ]);
217
218        return Response::create($factory)->redirect($this->url('', ['notice' => 'deleted']), 303);
219    }
220
221    /** @return array<string, mixed> */
222    private function form(Context $context, User $user, bool $exists, bool $profile = false): array
223    {
224        $locales = array_map(
225            static fn($locale) => ['id' => $locale->id, 'title' => $locale->title, 'fallback' => $locale->fallback],
226            iterator_to_array($context->locales(), false),
227        );
228        $self = $this->isSelf($user);
229
230        return $this->context([
231            'area' => $profile ? 'profile' : self::AREA,
232            'rail' => !$profile,
233            'profile' => $profile,
234            'exists' => $exists,
235            'action' => match (true) {
236                $profile => $this->panelPath() . '/profile',
237                $exists => $this->url('/' . $user->uid),
238                default => $this->url('/create/' . $user->type),
239            },
240            'deleteUrl' => $exists && !$self ? $this->url('/' . $user->uid . '/delete') : null,
241            'listUrl' => $this->url(),
242            'title' => $exists
243                ? $user->name ?? ($user->username !== '' ? $user->username : $user->email)
244                : __('user:new', ['type' => __($this->types->label($user->type))]),
245            'typeLabel' => __($this->types->label($user->type)),
246            'user' => $user,
247            'roles' => $this->roleChoices($user),
248            'locked' => $self,
249            'inspectorCollapsed' => $this->request->cookie('cosray_inspector', '') === 'collapsed',
250            'panelLocaleChoices' => $this->panelLocales(),
251            'fields' => $this->fields($context)->form($user),
252            'locales' => $locales,
253            'defaultLocale' => $context->locales()->getDefault()->id,
254            'system' => new System($this->config, $context->locales(), $this->csrfToken())->payload(),
255            'notice' => $this->notice(),
256        ]);
257    }
258
259    /**
260     * The submitted account and fields, validated against the stored user.
261     *
262     * @return array{?UserValues, ?string, list<array{message: string, path: list<string|int>}>}
263     */
264    private function submitted(Fields $fields, User $model, ?User $stored): array
265    {
266        $form = $this->formData();
267
268        // A submission cut short by max_input_vars would drop field content.
269        if (($form['_complete'] ?? null) !== '1') {
270            return [null, null, [['message' => __('editor:incomplete-form'), 'path' => []]]];
271        }
272
273        $text = static function (string $key) use ($form): ?string {
274            $value = is_string($form[$key] ?? null) ? trim($form[$key]) : '';
275
276            return $value === '' ? null : $value;
277        };
278        $password = is_string($form['password'] ?? null) && $form['password'] !== '' ? $form['password'] : null;
279        $account = [
280            'email' => $text('email'),
281            'username' => $text('username'),
282            'name' => $text('name'),
283            'password' => $password,
284        ];
285        $errors = $this->issues(
286            new Account($stored === null)
287                ->validate($account)
288                ->issues(),
289        );
290
291        if ($password !== null) {
292            $problem = $this->passwords->check($password, $account['email'], $account['username'], $account['name']);
293
294            if ($problem !== null) {
295                $errors[] = ['message' => $problem->message(), 'path' => ['password']];
296            }
297
298            if (!hash_equals($password, (string) ($form['password_repeat'] ?? ''))) {
299                $errors[] = ['message' => __('user:error-password-repeat'), 'path' => ['password_repeat']];
300            }
301        }
302
303        if ($account['email'] !== null) {
304            $taken = $this->users->taken($account['email'], $account['username'], $stored);
305
306            if ($taken['email']) {
307                $errors[] = ['message' => __('user:error-email-taken'), 'path' => ['email']];
308            }
309
310            if ($taken['username']) {
311                $errors[] = ['message' => __('user:error-username-taken'), 'path' => ['username']];
312            }
313        }
314
315        $roles = $stored->roles ?? [];
316        $active = $stored->active ?? true;
317
318        if ($stored === null || !$this->isSelf($stored)) {
319            $roles = $this->submittedRoles($model, $form['roles'] ?? []);
320            $active = ($form['active'] ?? null) === '1';
321        }
322
323        if (
324            $stored !== null
325            && $this->users->isLastSuperuser($stored)
326            && (
327                !$active
328                || !in_array('superuser', $roles, true)
329            )
330        ) {
331            $errors[] = ['message' => __('user:error-last-superuser'), 'path' => ['roles']];
332        }
333
334        $panelLocale = $text('panel_locale');
335        $shape = $fields->form($model);
336        $content = new FormPatch($shape['fields'])->content(
337            $shape['content'],
338            is_array($form['content'] ?? null) ? $form['content'] : [],
339        );
340
341        return [
342            [
343                'email' => (string) $account['email'],
344                'username' => $account['username'],
345                'name' => $account['name'],
346                'roles' => $roles,
347                'active' => $active,
348                'panelLocale' => isset($this->panelLocales()[$panelLocale ?? '']) ? $panelLocale : null,
349                'content' => $content,
350            ],
351            $password,
352            [...$errors, ...$this->issues($fields->validate($model, $content)->issues())],
353        ];
354    }
355
356    /**
357     * Roles the actor may hand out replace the ones submitted; roles the
358     * user holds beyond those stay untouched.
359     *
360     * @return list<string>
361     */
362    private function submittedRoles(User $user, mixed $submitted): array
363    {
364        $assignable = array_keys(array_filter(array_column($this->roleChoices($user), 'assignable', 'name')));
365        $submitted = is_array($submitted) ? $submitted : [];
366
367        return [
368            ...array_values(array_diff($user->roles, $assignable)),
369            ...array_values(array_intersect($assignable, $submitted)),
370        ];
371    }
372
373    /** @return list<array{name: string, label: string, held: bool, assignable: bool}> */
374    private function roleChoices(User $user): array
375    {
376        $actor = $this->currentUser();
377        $choices = [];
378
379        foreach ($this->types->roles($user->type, $this->policy) as $name => $label) {
380            $choices[] = [
381                'name' => $name,
382                'label' => $this->roleLabel($name, $label),
383                'held' => in_array($name, $user->roles, true),
384                'assignable' => $this->policy->covers($actor, $name),
385            ];
386        }
387
388        return $choices;
389    }
390
391    private function fields(Context $context): Fields
392    {
393        return new Fields($this->container->instance(Services::class), $context);
394    }
395
396    private function editable(string $uid): User
397    {
398        $user = $this->users->find($uid) ?? throw new HttpNotFound($this->request);
399
400        if (!$this->policy->covers($this->currentUser(), ...$user->roles)) {
401            throw new HttpForbidden($this->request);
402        }
403
404        return $user;
405    }
406
407    private function blank(string $type): User
408    {
409        if (!$this->types->has($type)) {
410            throw new HttpNotFound($this->request);
411        }
412
413        return new ($this->types->class($type))([
414            'usr' => 0,
415            'uid' => new Uid(Uid::ALPHABET_LOWERCASE_WORD_SAFE, 13)->generate(),
416            'type' => $type,
417            'email' => '',
418            'password' => '',
419            'active' => true,
420            'created' => '',
421            'changed' => '',
422            'deleted' => null,
423        ]);
424    }
425
426    /** Changing one's own password must not end the session doing it. */
427    private function keepOwnSession(User $user): void
428    {
429        $session = $this->request->get('session', null);
430        $fresh = $this->users->find($user->uid);
431
432        if ($this->isSelf($user) && $session instanceof Session && $fresh !== null) {
433            $session->setUser($fresh);
434        }
435    }
436
437    /**
438     * @param list<array{message: string, path: list<string|int>}> $errors
439     * @return array{saved: false, message: string, errors: list<array{message: string, path: list<string|int>}>}
440     */
441    private function rejected(array $errors): array
442    {
443        return ['saved' => false, 'message' => __('node:invalid-data'), 'errors' => $errors];
444    }
445
446    /**
447     * @param list<Issue> $issues
448     * @return list<array{message: string, path: list<string|int>}>
449     */
450    private function issues(array $issues): array
451    {
452        return array_map(
453            static fn(Issue $issue): array => ['message' => $issue->message, 'path' => $issue->path],
454            $issues,
455        );
456    }
457
458    /** @return array{uid: string, title: string, email: string, type: string, roles: list<string>, active: bool, url: ?string} */
459    private function listRow(User $user): array
460    {
461        $labels = $this->policy->roles();
462
463        return [
464            'uid' => $user->uid,
465            'title' => $user->name ?? ($user->username !== '' ? $user->username : $user->email),
466            'email' => $user->email,
467            'type' => __($this->types->label($user->type)),
468            'roles' => array_map(fn(string $role): string => $this->roleLabel(
469                $role,
470                $labels[$role] ?? $role,
471            ), $user->roles),
472            'active' => $user->active,
473            'url' => $this->policy->covers($this->currentUser(), ...$user->roles)
474                ? $this->url('/' . $user->uid)
475                : null,
476        ];
477    }
478
479    /** @return list<array{label: string, filterUrl: string, createUrl: string, active: bool}> */
480    private function typeLinks(?string $current, string $search): array
481    {
482        return array_map(fn(string $handle): array => [
483            'label' => __($this->types->label($handle)),
484            'filterUrl' => $this->url('', array_filter(['type' => $handle, 'q' => $search])),
485            'createUrl' => $this->url('/create/' . $handle),
486            'active' => $handle === $current,
487        ], $this->types->handles());
488    }
489
490    /** The shipped roles translate here; an app's roles through its own catalog. */
491    private function roleLabel(string $name, string $label): string
492    {
493        return match ($name) {
494            'superuser' => __('user:role-superuser'),
495            'admin' => __('user:role-admin'),
496            'editor' => __('user:role-editor'),
497            default => __($label),
498        };
499    }
500
501    private function notice(): ?string
502    {
503        return match ($this->request->param('notice', '')) {
504            'created' => __('user:notice-created'),
505            'deleted' => __('user:notice-deleted'),
506            default => null,
507        };
508    }
509
510    /** @param array<string, mixed> $query */
511    private function url(string $path = '', array $query = []): string
512    {
513        $url = $this->panelPath() . '/users' . $path;
514
515        return $query === [] ? $url : $url . '?' . http_build_query($query);
516    }
517
518    private function isSelf(User $user): bool
519    {
520        return $user->id === $this->currentUser()->id;
521    }
522
523    /**
524     * The stored user keeps only its last editor, so the log is where a
525     * granted or revoked access stays visible.
526     *
527     * @param list<string> $roles
528     */
529    private function logAccessChanges(User $user, array $roles, bool $active): void
530    {
531        $context = ['login' => $user->loginName(), 'actor' => $this->currentUser()->loginName()];
532        $before = $user->roles;
533        sort($before);
534        sort($roles);
535
536        if ($before !== $roles) {
537            $this->logger->notice('Roles of {login} changed from {before} to {after} by {actor}', [
538                ...$context,
539                'before' => self::roleList($before),
540                'after' => self::roleList($roles),
541            ]);
542        }
543
544        if ($active !== $user->active) {
545            $this->logger->notice(
546                $active ? 'User {login} activated by {actor}' : 'User {login} deactivated by {actor}',
547                $context,
548            );
549        }
550    }
551
552    /** @param list<string> $roles */
553    private static function roleList(array $roles): string
554    {
555        return $roles === [] ? 'none' : implode(', ', $roles);
556    }
557}