• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

lonnieezell / Bonfire2 / 37835748787

08 Oct 2026 07:56PM UTC coverage: 48.83% (+3.7%) from 45.132%
37835748787

push

github

web-flow
refactor(#583): concentrate Users admin access rules in one module (#589)

Add UserAccess (who may touch whom), AvatarStorage (avatar path, store,
delete) and SaveUser (the save orchestration moved out of the HTTP
method). The controller and the form, permissions and table views now
ask UserAccess instead of repeating the rules.

Purging several users now removes every user's avatar and meta, not only
the first. Nobody with users.edit can ban themselves any more, whether
or not they have me.edit. Re-enable testCanEditUser.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>

100 of 118 new or added lines in 5 files covered. (84.75%)

6 existing lines in 1 file now uncovered.

1732 of 3547 relevant lines covered (48.83%)

14.06 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

40.28
/src/Users/Controllers/UserController.php
1
<?php
2

3
/**
4
 * This file is part of Bonfire.
5
 *
6
 * (c) Lonnie Ezell <lonnieje@gmail.com>
7
 *
8
 * For the full copyright and license information, please view
9
 * the LICENSE file that was distributed with this source code.
10
 */
11

12
namespace Bonfire\Users\Controllers;
13

14
use Bonfire\Core\AdminController;
15
use Bonfire\Users\Libraries\AvatarStorage;
16
use Bonfire\Users\Libraries\SaveUser;
17
use Bonfire\Users\Libraries\UserAccess;
18
use Bonfire\Users\Models\UserFilter;
19
use Bonfire\Users\Models\UserModel;
20
use Bonfire\Users\User;
21
use CodeIgniter\HTTP\RedirectResponse;
22
use CodeIgniter\Shield\Models\LoginModel;
23
use CodeIgniter\Shield\Models\UserIdentityModel;
24
use ReflectionException;
25

26
class UserController extends AdminController
27
{
28
    protected $theme      = 'Admin';
29
    protected $viewPrefix = 'Bonfire\Users\Views\\';
30

31
    /**
32
     * Display the uses currently in the system.
33
     *
34
     * @return RedirectResponse|string
35
     */
36
    public function list()
37
    {
38
        if (! auth()->user()->can('users.list')) {
1 ✔
39
            return redirect()->to(ADMIN_AREA)->with('error', lang('Bonfire.notAuthorized'));
×
40
        }
41

42
        /** @var UserFilter $userModel */
43
        $userModel = model(UserFilter::class);
1 ✔
44

45
        $userModel->filter($this->request->getGet('filters'))
1 ✔
46
            ->withPermissions()
1 ✔
47
            ->withIdentities()
1 ✔
48
            ->withGroups();
1 ✔
49

50
        $view = $this->request->hasHeader('HX-Request')
1 ✔
51
            ? $this->viewPrefix . '_table'
×
52
            : $this->viewPrefix . 'list';
1 ✔
53

54
        return $this->render($view, [
1 ✔
55
            'headers' => [
1 ✔
56
                'email'       => lang('Users.headers.email'),
1 ✔
57
                'username'    => lang('Users.headers.username'),
1 ✔
58
                'groups'      => lang('Users.headers.groups'),
1 ✔
59
                'last_active' => lang('Users.headers.last_active'),
1 ✔
60
            ],
1 ✔
61
            'showSelectAll' => true,
1 ✔
62
            'users'         => $userModel->paginate(setting('Site.perPage')),
1 ✔
63
            'pager'         => $userModel->pager,
1 ✔
64
            'access'        => UserAccess::forCurrentUser(),
1 ✔
65
        ]);
1 ✔
66
    }
67

68
    /**
69
     * Display the "new user" form.
70
     */
71
    public function create()
72
    {
73
        if (! auth()->user()->can('users.create')) {
×
74
            return redirect()->to(ADMIN_AREA . '/users')->with('error', lang('Bonfire.notAuthorized'));
×
75
        }
76

77
        $groups = setting('AuthGroups.groups');
×
78
        asort($groups);
×
79

80
        helper('form');
×
81

82
        return $this->render($this->viewPrefix . 'form', [
×
83
            'groups' => $groups,
×
NEW
84
            'access' => UserAccess::forCurrentUser(),
×
UNCOV
85
        ]);
×
86
    }
87

88
    /**
89
     * Display the Edit form for a single user.
90
     *
91
     * @return RedirectResponse|string
92
     */
93
    public function edit(int $userId)
94
    {
95
        $access = UserAccess::forCurrentUser();
1 ✔
96
        if (! $access->canOpenEditForm($userId)) {
1 ✔
UNCOV
97
            return redirect()->back()->with('error', lang('Bonfire.notAuthorized'));
×
98
        }
99

100
        $users = new UserModel();
1 ✔
101

102
        $user = $users->find($userId);
1 ✔
103
        if ($user === null) {
1 ✔
104
            return redirect()->back()->with('error', lang('Bonfire.resourceNotFound', [lang('Users.userGenitive')]));
×
105
        }
106

107
        $groups = setting('AuthGroups.groups');
1 ✔
108
        asort($groups);
1 ✔
109

110
        helper('form');
1 ✔
111

112
        return $this->render($this->viewPrefix . 'form', [
1 ✔
113
            'user'   => $user,
1 ✔
114
            'groups' => $groups,
1 ✔
115
            'access' => $access,
1 ✔
116
        ]);
1 ✔
117
    }
118

119
    /**
120
     * Creates or saves the basic user details.
121
     *
122
     * @return RedirectResponse|void
123
     *
124
     * @throws ReflectionException
125
     */
126
    public function save(?int $userId = null)
127
    {
128
        $access = UserAccess::forCurrentUser();
1 ✔
129
        if (! $access->canSave($userId)) {
1 ✔
UNCOV
130
            return redirect()->back()->with('error', lang('Bonfire.notAuthorized'));
×
131
        }
132

133
        $users = new UserModel();
1 ✔
134
        /** @var User */
135
        $user = $userId !== null
1 ✔
136
            ? $users->find($userId)
1 ✔
137
            : new User();
×
138

139
        /** @phpstan-ignore-next-line */
140
        if ($user === null) {
1 ✔
141
            return redirect()->back()->withInput()->with('error', lang('Bonfire.resourceNotFound', [lang('Users.userGenitive')]));
×
142
        }
143

144
        /**
145
         * Perform validation here so we can merge the
146
         * basic model validation rules with the meta info rules.
147
         *
148
         * @var array
149
         */
150
        $rules = config('Users')->validation;
1 ✔
151
        $rules = array_merge($rules, $user->validationRules('meta'));
1 ✔
152

153
        if (! $this->validate($rules)) {
1 ✔
154
            return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
×
155
        }
156

157
        $user = (new SaveUser($access, new AvatarStorage(), $users))
1 ✔
158
            ->handle($user, $this->request->getPost(), $this->request->getFile('avatar'));
1 ✔
159

160
        return redirect()->to($user->adminLink())->with('message', lang('Bonfire.resourceSaved', [lang('Users.user')]));
1 ✔
161
    }
162

163
    /**
164
     * Change user's password.
165
     *
166
     * @return RedirectResponse|void
167
     *
168
     * @throws ReflectionException
169
     */
170
    public function changePassword(?int $userId = null)
171
    {
172
        if (! UserAccess::forCurrentUser()->canManageSecurity($userId)) {
1 ✔
173
            return redirect()->back()->with('error', lang('Bonfire.notAuthorized'));
×
174
        }
175

176
        $users = new UserModel();
1 ✔
177
        /** @var User */
178
        $user = $userId !== null
1 ✔
179
            ? $users->find($userId)
1 ✔
180
            : new User();
×
181

182
        /** @phpstan-ignore-next-line */
183
        if ($user === null) {
1 ✔
184
            return redirect()->back()->withInput()->with('error', lang('Bonfire.resourceNotFound', [lang('Users.userGenitive')]));
×
185
        }
186

187
        if (! $this->validate(['password' => 'required|strong_password', 'pass_confirm' => 'required|matches[password]'])) {
1 ✔
188
            return redirect()->back()->withInput()->with('errors', service('validation')->getErrors());
×
189
        }
190

191
        // Save the new user's email/password
192
        $password = $this->request->getPost('password');
1 ✔
193
        $identity = $user->getEmailIdentity();
1 ✔
194

195
        if ($password !== null) {
1 ✔
196
            $identity->secret2 = service('passwords')->hash($password);
1 ✔
197
        }
198

199
        if ($identity->hasChanged()) {
1 ✔
200
            model(UserIdentityModel::class)->save($identity);
1 ✔
201
        }
202

203
        return redirect()->to($user->adminLink('/security'))->with('message', lang('Bonfire.resourceSaved', [lang('Users.user')]));
1 ✔
204
    }
205

206
    /**
207
     * Delete the specified user.
208
     *
209
     * @return RedirectResponse
210
     */
211
    public function delete(int $userId)
212
    {
213
        if (! auth()->user()->can('users.delete')) {
×
214
            return redirect()->back()->with('error', lang('Bonfire.notAuthorized'));
×
215
        }
216

217
        $users = model(UserModel::class);
×
218
        /** @var User|null $user */
219
        $user = $users->find($userId);
×
220

221
        if ($user === null) {
×
222
            return redirect()->back()->with('error', lang('Bonfire.resourceNotFound', [lang('Users.userGenitive')]));
×
223
        }
224

225
        if (! $users->delete($user->id)) {
×
226
            log_message('error', implode(' ', $users->errors()));
×
227

228
            return redirect()->back()->with('error', lang('Bonfire.unknownError'));
×
229
        }
230

231
        return redirect()->back()->with('message', lang('Bonfire.resourceDeleted', [lang('Users.user')]));
×
232
    }
233

234
    /**
235
     * Deletes multiple users from the database.
236
     * Called via the checked() records in the table.
237
     */
238
    public function deleteBatch()
239
    {
240
        if (! auth()->user()->can('users.delete')) {
×
241
            return redirect()->back()->with('error', lang('Bonfire.notAuthorized'));
×
242
        }
243

244
        $ids = $this->request->getPost('selects');
×
245

246
        if (empty($ids)) {
×
247
            return redirect()->back()->with('error', lang('Bonfire.resourcesNotSelected', [lang('Users.users')]));
×
248
        }
249
        $ids = array_keys($ids);
×
250

251
        $users = model(UserModel::class);
×
252

253
        if (! $users->delete($ids)) {
×
254
            log_message('error', implode(' ', $users->errors()));
×
255

256
            return redirect()->back()->with('error', lang('Bonfire.unknownError'));
×
257
        }
258

259
        return redirect()->back()->with('message', lang('Bonfire.resourcesDeleted', [lang('Users.users')]));
×
260
    }
261

262
    /**
263
     * Displays basic security info, like previous login info,
264
     * and ability to force a password reset, ban, etc.
265
     *
266
     * @return RedirectResponse|string
267
     */
268
    public function security(int $userId)
269
    {
NEW
270
        if (! UserAccess::forCurrentUser()->canManageSecurity($userId)) {
×
271
            return redirect()->to(ADMIN_AREA)->with('error', lang('Bonfire.notAuthorized'));
×
272
        }
273

274
        $users = model(UserModel::class);
×
275
        /** @var User|null $user */
276
        $user = $users->find($userId);
×
277
        if ($user === null) {
×
278
            return redirect()->back()->with('error', lang('Bonfire.resourceNotFound', [lang('Users.userGenitive')]));
×
279
        }
280

281
        /** @var LoginModel $loginModel */
282
        $loginModel = model(LoginModel::class);
×
283
        $logins     = $loginModel->where('identifier', $user->email)->orderBy('date', 'desc')->findAll(20);
×
284

285
        return $this->render($this->viewPrefix . 'security', [
×
286
            'user'   => $user,
×
287
            'logins' => $logins,
×
288
        ]);
×
289
    }
290

291
    /**
292
     * Displays basic security info, like previous login info,
293
     * and ability to force a password reset, ban, etc.
294
     *
295
     * @return RedirectResponse|string
296
     */
297
    public function permissions(int $userId)
298
    {
299
        if (! auth()->user()->can('users.view')) {
×
300
            return redirect()->to(ADMIN_AREA)->with('error', lang('Bonfire.notAuthorized'));
×
301
        }
302

303
        $users = model(UserModel::class);
×
304
        $user  = $users->find($userId);
×
305
        if ($user === null) {
×
306
            return redirect()->back()->with('error', lang('Bonfire.resourceNotFound', [lang('Users.userGenitive')]));
×
307
        }
308

309
        $permissions = setting('AuthGroups.permissions');
×
310
        if (is_array($permissions)) {
×
311
            ksort($permissions);
×
312
        }
313

314
        return $this->render($this->viewPrefix . 'permissions', [
×
315
            'user'        => $user,
×
316
            'permissions' => $permissions,
×
NEW
317
            'access'      => UserAccess::forCurrentUser(),
×
UNCOV
318
        ]);
×
319
    }
320

321
    /**
322
     * Updates the permissions for a single user.
323
     *
324
     * @return RedirectResponse
325
     */
326
    public function savePermissions(int $userId)
327
    {
NEW
328
        $access = UserAccess::forCurrentUser();
×
NEW
329
        if (! $access->canEditUsers()) {
×
UNCOV
330
            return redirect()->to(ADMIN_AREA)->with('error', lang('Bonfire.notAuthorized'));
×
331
        }
332

333
        $users = model(UserModel::class);
×
334
        /** @var User|null $user */
335
        $user = $users->find($userId);
×
336
        if ($user === null) {
×
337
            return redirect()->back()->with('error', lang('Bonfire.resourceNotFound', [lang('Users.userGenitive')]));
×
338
        }
339

NEW
340
        $permissions = $access->allowedPermissions($user, $this->request->getPost('permissions') ?? []);
×
341

342
        $user->syncPermissions(...$permissions);
×
343

344
        return redirect()->back()->with('message', lang('Bonfire.resourceSaved', [lang('Users.permissions')]));
×
345
    }
346

347
    /**
348
     * Deletes user avatar on HTMX ajax request
349
     *
350
     * @return string
351
     */
352
    public function deleteAvatar(int $userId)
353
    {
UNCOV
354
        $users = new UserModel();
×
355
        /** @var User */
356
        $user = $users->find($userId);
×
357

NEW
358
        if (UserAccess::forCurrentUser()->canSave($userId)) {
×
NEW
359
            if ((new AvatarStorage())->delete($user->avatar)) {
×
360
                $user->avatar = null;
×
361
                $users->save($user);
×
362
            }
363

364
            return $this->render($this->viewPrefix . '_avatar', ['user' => $user]);
×
365
        }
366

367
        // TODO: will have to find a way to return error message via ajax fragment later
368
        return '';
×
369
    }
370
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc