Passed
Push — main ( 122379...eaa951 )
by Nobufumi
02:26
created

UserModel::isDemotingLastAdmin()   A

Complexity

Conditions 4
Paths 2

Size

Total Lines 12
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 4
eloc 7
c 1
b 0
f 0
nc 2
nop 2
dl 0
loc 12
rs 10
1
<?php
2
3
namespace Jidaikobo\Kontiki\Models;
4
5
class UserModel extends BaseModel
6
{
7
    use Traits\CRUDTrait;
8
    use Traits\MetaDataTrait;
0 ignored issues
show
introduced by
The trait Jidaikobo\Kontiki\Models\Traits\MetaDataTrait requires some properties which are not provided by Jidaikobo\Kontiki\Models\UserModel: $meta_key, $meta_value
Loading history...
9
    use Traits\IndexTrait;
10
11
    protected string $table = 'users';
12
13
    protected function defineFieldDefinitions(): void
14
    {
15
        // add dynamic rules at $this->processFieldDefinitions()
16
        $this->fieldDefinitions = [
17
            'id' => $this->getIdField(),
18
19
            'username' => $this->getField(
20
                __('username'),
21
                [
22
                    'rules' => [
23
                        'required',
24
                        ['lengthMin', 3]
25
                    ],
26
                    'display_in_list' => true
27
                ]
28
            ),
29
30
            'password' => $this->getField(
31
                __('password'),
32
                [
33
                    'type' => 'password',
34
                    'rules' => [
35
                        'required',
36
                        ['lengthMin', 8]
37
                    ],
38
                    'filter' => FILTER_UNSAFE_RAW,
39
                ]
40
            ),
41
42
            'role' => $this->getField(
43
                __('role'),
44
                [
45
                    'type' => 'select',
46
                    'options' => [
47
                        'editor' => __('editor'),
48
                        'admin' => __('admin'),
49
                    ],
50
                    'rules' => [
51
                        'required',
52
                    ],
53
                    'attributes' => [
54
                        'class' => 'form-control form-select'
55
                    ],
56
                    'display_in_list' => true
57
                ]
58
            ),
59
60
            'created_at' => $this->getReadOnlyField(
61
                __('created_at', 'Created'),
62
                [
63
                    'display_in_list' => true
64
                ]
65
            ),
66
        ];
67
    }
68
69
    protected function processFieldDefinitions(
70
        string $context = '',
71
        array $data = [],
72
        int $id = null
73
    ): void {
74
        // add rule
75
        $this->fieldDefinitions['username']['rules'][] = [
76
            'unique',
77
            $this->table,
78
            'username',
79
            $id
80
        ];
81
82
        if ($context == 'create') {
83
            return;
84
        }
85
86
        // Exclude `required` from password's rules
87
        // No password specified means no change
88
        $this->fieldDefinitions['password']['rules'] = array_filter(
89
            $this->fieldDefinitions['password']['rules'],
90
            fn($rule) => $rule !== 'required'
91
        );
92
93
        $this->fieldDefinitions['password']['description'] = __('users_edit_message');
94
95
        // disable form elements
96
        if (in_array($context, ['trash', 'restore', 'delete'])) {
97
            $this->disableFormFieldsForContext();
98
        }
99
    }
100
101
    public function validate(array $data, array $context): array
102
    {
103
        $result = parent::validate($data, $context);
104
        $adminCheck = $this->atLeastOneAdmin($data, $context);
105
        $deleteCheck = $this->cannotDeleteAdmin($context);
106
107
        return [
108
            'valid' => $result['valid'] && $adminCheck['valid'] && $deleteCheck['valid'],
109
            'errors' => array_merge_recursive(
110
                $result['errors'],
111
                $adminCheck['errors'],
112
                $deleteCheck['errors']
113
            )
114
        ];
115
    }
116
117
    private function atLeastOneAdmin(array $data, array $context): array
118
    {
119
        $result = [
120
            'valid' => true,
121
            'errors' => []
122
        ];
123
124
        $id = $context['id'] ?? 0;
125
        if (!$id || !isset($data['role'])) {
126
            return $result;
127
        }
128
129
        $targetUser = $this->getById($id);
130
131
        if ($this->isDemotingLastAdmin($targetUser, $data)) {
132
            $result['valid'] = false;
133
            $result['errors']['role']['messages'] = [__('at_least_one_admin')];
134
        }
135
136
        return $result;
137
    }
138
139
    /**
140
     * Check if the given user is the last admin and being demoted.
141
     */
142
    private function isDemotingLastAdmin(?array $user, array $newData): bool
143
    {
144
        if (!$user || $user['role'] !== 'admin' || $newData['role'] === 'admin') {
0 ignored issues
show
Bug Best Practice introduced by
The expression $user of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
145
            return false;
146
        }
147
148
        $otherAdmins = $this->db->table($this->table)
149
            ->where('role', 'admin')
150
            ->where('id', '!=', $user['id'])
151
            ->count();
152
153
        return $otherAdmins === 0;
154
    }
155
156
    private function cannotDeleteAdmin(array $context): array
157
    {
158
        $result = [
159
            'valid' => true,
160
            'errors' => []
161
        ];
162
163
        if (($context['context'] ?? '') !== 'delete') {
164
            return $result;
165
        }
166
167
        $id = $context['id'] ?? 0;
168
        $targetUser = $this->getById($id);
169
170
        if ($targetUser['role'] !== 'admin') return $result;
171
        $result['valid'] = false;
172
        $result['errors']['role']['messages'] = [__('cannot_delete_admin')];
173
174
        return $result;
175
    }
176
177
    private function hashPassword(string $password): string
178
    {
179
        return password_hash($password, PASSWORD_BCRYPT);
0 ignored issues
show
Bug Best Practice introduced by
The expression return password_hash($pa...Models\PASSWORD_BCRYPT) could return the type null which is incompatible with the type-hinted return string. Consider adding an additional type-check to rule them out.
Loading history...
180
    }
181
182
    protected function processDataForForm(string $actionType, array $data): array
183
    {
184
        if ($actionType == 'edit') {
185
            $data['password'] = '';
186
        }
187
        return $data;
188
    }
189
190
    protected function afterProcessDataBeforeSave(string $context, array $data): array
191
    {
192
        if ($context == 'create') {
193
            $data['password'] = $this->hashPassword($data['password']);
194
        }
195
196
        if ($context == 'update') {
197
            // Branching password processing
198
            if (isset($data['password'])) {
199
                if (trim($data['password']) === '') {
200
                    unset($data['password']);
201
                } else {
202
                    $data['password'] = $this->hashPassword($data['password']);
203
                }
204
            }
205
        }
206
        return $data;
207
    }
208
}
209