Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
96.63% |
258 / 267 |
|
79.31% |
23 / 29 |
CRAP | |
0.00% |
0 / 1 |
| Users | |
96.63% |
258 / 267 |
|
79.31% |
23 / 29 |
96 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| context | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
2 | |||
| index | |
100.00% |
28 / 28 |
|
100.00% |
1 / 1 |
8 | |||
| create | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| store | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
| edit | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| update | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| profile | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| saveProfile | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| save | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
8 | |||
| delete | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
3.01 | |||
| form | |
96.88% |
31 / 32 |
|
0.00% |
0 / 1 |
9 | |||
| submitted | |
95.00% |
57 / 60 |
|
0.00% |
0 / 1 |
20 | |||
| submittedRoles | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| roleChoices | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
2 | |||
| fields | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| editable | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| blank | |
92.31% |
12 / 13 |
|
0.00% |
0 / 1 |
2.00 | |||
| keepOwnSession | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
4 | |||
| rejected | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| issues | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| listRow | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
3 | |||
| typeLinks | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| roleLabel | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
5 | |||
| notice | |
60.00% |
3 / 5 |
|
0.00% |
0 / 1 |
5.02 | |||
| url | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| isSelf | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| logAccessChanges | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
4 | |||
| roleList | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Cosray\Controller\Panel; |
| 6 | |
| 7 | use Celema\Container\Container; |
| 8 | use Celema\Core\Exception\HttpForbidden; |
| 9 | use Celema\Core\Exception\HttpNotFound; |
| 10 | use Celema\Core\Factory\Factory; |
| 11 | use Celema\Core\Request; |
| 12 | use Celema\Core\Response; |
| 13 | use Celema\Sire\Issue; |
| 14 | use Cosray\Config; |
| 15 | use Cosray\Context; |
| 16 | use Cosray\Field\Services; |
| 17 | use Cosray\Middleware\Permission; |
| 18 | use Cosray\Panel\FormPatch; |
| 19 | use Cosray\Panel\System; |
| 20 | use Cosray\Security\Policy; |
| 21 | use Cosray\Session; |
| 22 | use Cosray\Uid; |
| 23 | use Cosray\User; |
| 24 | use Cosray\User\Fields; |
| 25 | use Cosray\User\PasswordPolicy; |
| 26 | use Cosray\User\Types; |
| 27 | use Cosray\Users as UserStore; |
| 28 | use Cosray\Validation\Account; |
| 29 | use Override; |
| 30 | use Psr\Log\LoggerInterface as Logger; |
| 31 | |
| 32 | /** @phpstan-import-type UserValues from \Cosray\Users */ |
| 33 | final 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 | } |