Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
267 / 267
100.00% covered (success)
100.00%
23 / 23
CRAP
100.00% covered (success)
100.00%
1 / 1
SettingsController
100.00% covered (success)
100.00%
267 / 267
100.00% covered (success)
100.00%
23 / 23
77
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 profile
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
2
 saveProfile
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
21
 password
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 savePassword
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
5
 theme
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 saveTheme
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 language
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 saveLanguage
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 certificate
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 saveIfsgCertificate
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
 saveDrivingLicense
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
4
 api
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 apiKeyReset
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 oauth
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 sessions
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 sessionsDelete
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 settingsMenu
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
9
 checkOauthHidden
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 checkDrivingLicense
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 checkIfsgCertificate
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 isRequired
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getSaveProfileRules
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5namespace Engelsystem\Controllers;
6
7use Engelsystem\Config\Config;
8use Engelsystem\Config\GoodieType;
9use Engelsystem\Helpers\Authenticator;
10use Engelsystem\Http\Exceptions\HttpNotFound;
11use Engelsystem\Http\Redirector;
12use Engelsystem\Http\Request;
13use Engelsystem\Http\Response;
14use Engelsystem\Models\AngelType;
15use Engelsystem\Models\User\User;
16use Psr\Log\LoggerInterface;
17
18class SettingsController extends BaseController
19{
20    use HasUserNotifications;
21    use ChecksArrivalsAndDepartures;
22
23    /** @var string[] */
24    protected array $permissions = [
25        'user_settings',
26        'api' => 'api||shifts_json_export||ical||atom',
27        'apiKeyReset' => 'api||shifts_json_export||ical||atom',
28    ];
29
30    public function __construct(
31        protected Authenticator $auth,
32        protected Config $config,
33        protected LoggerInterface $log,
34        protected Redirector $redirect,
35        protected Response $response
36    ) {
37    }
38
39    public function profile(): Response
40    {
41        $user = $this->auth->user();
42        $requiredFields = $this->config->get('required_user_fields');
43
44        return $this->response->withView(
45            'pages/settings/profile',
46            [
47                'settings_menu' => $this->settingsMenu(),
48                'userdata' => $user,
49                'goodie_enabled' => $this->config->get('goodie_type') !== GoodieType::None->value
50                    && config('enable_email_goodie'),
51                'goodie_tshirt' => $this->config->get('goodie_type') === GoodieType::Tshirt->value,
52                'tShirtLink' => $this->config->get('tshirt_link'),
53                'isPronounRequired' => in_array('pronoun', $requiredFields),
54                'isFirstnameRequired' => in_array('firstname', $requiredFields),
55                'isLastnameRequired' => in_array('lastname', $requiredFields),
56                'isTShirtSizeRequired' => in_array('tshirt_size', $requiredFields),
57                'isMobileRequired' => in_array('mobile', $requiredFields),
58                'isDectRequired' => in_array('dect', $requiredFields),
59            ]
60        );
61    }
62
63    public function saveProfile(Request $request): Response
64    {
65        $user = $this->auth->user();
66        $data = $this->validate($request, $this->getSaveProfileRules($user));
67        $goodie = GoodieType::from(config('goodie_type'));
68        $goodie_enabled = $goodie !== GoodieType::None;
69        $goodie_tshirt = $goodie === GoodieType::Tshirt;
70
71        if (config('enable_pronoun')) {
72            $user->personalData->pronoun = $data['pronoun'];
73        }
74
75        if (config('enable_full_name')) {
76            $user->personalData->first_name = $data['first_name'];
77            $user->personalData->last_name = $data['last_name'];
78        }
79
80        if (config('enable_planned_arrival')) {
81            if (!$this->isArrivalDateValid($data['planned_arrival_date'], $data['planned_departure_date'])) {
82                $this->addNotification('settings.profile.planned_arrival_date.invalid', NotificationType::ERROR);
83                return $this->redirect->to('/settings/profile');
84            } elseif (!$this->isDepartureDateValid($data['planned_arrival_date'], $data['planned_departure_date'])) {
85                $this->addNotification('settings.profile.planned_departure_date.invalid', NotificationType::ERROR);
86                return $this->redirect->to('/settings/profile');
87            } else {
88                $user->personalData->planned_arrival_date = $data['planned_arrival_date'];
89                $user->personalData->planned_departure_date = $data['planned_departure_date'] ?: null;
90            }
91        }
92
93        if (config('enable_dect')) {
94            $user->contact->dect = $data['dect'];
95        }
96
97        $user->contact->mobile = $data['mobile'];
98
99        if (config('enable_mobile_show')) {
100            $user->settings->mobile_show = $data['mobile_show'] ?: false;
101        }
102
103        $user->email = $data['email'];
104        $user->settings->email_shiftinfo = $data['email_shiftinfo'] ?: false;
105        $user->settings->email_news = $data['email_news'] ?: false;
106        $user->settings->email_human = $data['email_human'] ?: false;
107        $user->settings->email_messages = $data['email_messages'] ?: false;
108
109        if ($goodie_enabled && config('enable_email_goodie')) {
110            $user->settings->email_goodie = $data['email_goodie'] ?: false;
111        }
112
113        if (
114            $goodie_tshirt
115            && !$user->state->got_goodie
116            && (isset(config('tshirt_sizes')[$data['shirt_size'] ?? '']) || is_null($data['shirt_size']))
117        ) {
118            $user->personalData->shirt_size = $data['shirt_size'];
119        }
120
121        $user->personalData->save();
122        $user->contact->save();
123        $user->settings->save();
124        $user->save();
125
126        $this->addNotification('settings.success');
127
128        return $this->redirect->to('/settings/profile');
129    }
130
131    public function password(): Response
132    {
133        return $this->response->withView(
134            'pages/settings/password',
135            [
136                'settings_menu' => $this->settingsMenu(),
137                'min_length'    => config('password_min_length'),
138            ]
139        );
140    }
141
142    public function savePassword(Request $request): Response
143    {
144        $user = $this->auth->user();
145
146        $minLength = config('password_min_length');
147        $data = $this->validate($request, [
148            'password'      => empty($user->password) ? 'optional' : 'required',
149            'new_password'  => 'required|length:' . $minLength,
150            'new_password2' => 'required',
151        ]);
152
153        if (!empty($user->password) && !$this->auth->verifyPassword($user, $data['password'])) {
154            $this->addNotification('auth.password.error', NotificationType::ERROR);
155        } elseif ($data['new_password'] != $data['new_password2']) {
156            $this->addNotification('validation.password.confirmed', NotificationType::ERROR);
157        } else {
158            $this->auth->setPassword($user, $data['new_password']);
159
160            $this->addNotification('settings.password.success');
161            $this->log->info('User set new password.');
162
163            $user->sessions()
164                ->getQuery()
165                ->where('id', '!=', session()->getId())
166                ->delete();
167        }
168
169        return $this->redirect->to('/settings/password');
170    }
171
172    public function theme(): Response
173    {
174        $themes = array_map(function ($theme) {
175            return $theme['name'];
176        }, config('themes'));
177
178        $currentTheme = $this->auth->user()->settings->theme;
179
180        return $this->response->withView(
181            'pages/settings/theme',
182            [
183                'settings_menu' => $this->settingsMenu(),
184                'themes'        => $themes,
185                'current_theme' => $currentTheme,
186            ]
187        );
188    }
189
190    public function saveTheme(Request $request): Response
191    {
192        $user = $this->auth->user();
193        $data = $this->validate($request, ['select_theme' => 'int|in:' . implode(',', array_keys(config('themes')))]);
194        $selectTheme = $data['select_theme'];
195
196        $user->settings->theme = $selectTheme;
197        $user->settings->save();
198
199        $this->addNotification('settings.theme.success');
200
201        return $this->redirect->to('/settings/theme');
202    }
203
204    public function language(): Response
205    {
206        $languages = array_flip(config('locales'));
207        array_walk($languages, function (&$value, $key): void {
208            $value = 'language.' . $key;
209        });
210
211        $currentLanguage = $this->auth->user()->settings->language;
212
213        return $this->response->withView(
214            'pages/settings/language',
215            [
216                'settings_menu'    => $this->settingsMenu(),
217                'languages'        => $languages,
218                'current_language' => $currentLanguage,
219            ]
220        );
221    }
222
223    public function saveLanguage(Request $request): Response
224    {
225        $user = $this->auth->user();
226        $data = $this->validate($request, ['select_language' => 'required|in:' . implode(',', config('locales'))]);
227        $selectLanguage = $data['select_language'];
228
229        $user->settings->language = $selectLanguage;
230        $user->settings->save();
231
232        session()->set('locale', $selectLanguage);
233
234        $this->addNotification('settings.language.success');
235
236        return $this->redirect->to('/settings/language');
237    }
238
239    public function certificate(): Response
240    {
241        if (
242            !(config('ifsg_enabled') && $this->checkIfsgCertificate())
243            && !(config('driving_license_enabled') && $this->checkDrivingLicense())
244        ) {
245            throw new HttpNotFound();
246        }
247
248        $user = $this->auth->user();
249        return $this->response->withView(
250            'pages/settings/certificates',
251            [
252                'settings_menu' => $this->settingsMenu(),
253                'driving_license' => $this->checkDrivingLicense(),
254                'ifsg' => $this->checkIfsgCertificate(),
255                'certificates' => $user->license,
256            ]
257        );
258    }
259
260    public function saveIfsgCertificate(Request $request): Response
261    {
262        $user = $this->auth->user();
263        if (!config('ifsg_enabled') || $user->license->ifsg_confirmed || !$this->checkIfsgCertificate()) {
264            throw new HttpNotFound();
265        }
266
267        $data = $this->validate($request, [
268            'ifsg_certificate_light' => 'optional|checked',
269            'ifsg_certificate' => 'optional|checked',
270        ]);
271
272        if (config('ifsg_light_enabled')) {
273            $user->license->ifsg_certificate_light = !$data['ifsg_certificate'] && $data['ifsg_certificate_light'];
274        }
275        $user->license->ifsg_certificate = (bool) $data['ifsg_certificate'];
276
277        $user->license->save();
278        $this->addNotification('settings.certificates.success');
279
280        return $this->redirect->to('/settings/certificates');
281    }
282
283    public function saveDrivingLicense(Request $request): Response
284    {
285        if (!config('driving_license_enabled') || !$this->checkDrivingLicense()) {
286            throw new HttpNotFound();
287        }
288
289        $user = $this->auth->user();
290        $data = $this->validate($request, [
291            'has_car' => 'optional|checked',
292            'drive_car' => 'optional|checked',
293            'drive_3_5t' => 'optional|checked',
294            'drive_7_5t' => 'optional|checked',
295            'drive_12t' => 'optional|checked',
296            'drive_forklift' => 'optional|checked',
297        ]);
298
299        $user->license->has_car = (bool) $data['has_car'];
300        if (!$user->license->drive_confirmed) {
301            $user->license->drive_car = (bool) $data['drive_car'];
302            $user->license->drive_3_5t = (bool) $data['drive_3_5t'];
303            $user->license->drive_7_5t = (bool) $data['drive_7_5t'];
304            $user->license->drive_12t = (bool) $data['drive_12t'];
305            $user->license->drive_forklift = (bool) $data['drive_forklift'];
306        }
307        $user->license->save();
308
309        $this->addNotification('settings.certificates.success');
310
311        return $this->redirect->to('/settings/certificates');
312    }
313
314    public function api(): Response
315    {
316        return $this->response->withView(
317            'pages/settings/api',
318            [
319                'settings_menu' => $this->settingsMenu(),
320            ],
321        );
322    }
323
324    public function apiKeyReset(): Response
325    {
326        $this->auth->resetApiKey($this->auth->user());
327
328        $this->addNotification('settings.api.key_reset_success');
329        return $this->redirect->back();
330    }
331
332    public function oauth(): Response
333    {
334        $providers = $this->config->get('oauth');
335        if (empty($providers)) {
336            throw new HttpNotFound();
337        }
338
339        return $this->response->withView(
340            'pages/settings/oauth',
341            [
342                'settings_menu' => $this->settingsMenu(),
343                'providers'     => $providers,
344            ],
345        );
346    }
347
348    public function sessions(): Response
349    {
350        $sessions = $this->auth->user()->sessions->sortByDesc('last_activity');
351
352        return $this->response->withView(
353            'pages/settings/sessions',
354            [
355                'settings_menu' => $this->settingsMenu(),
356                'sessions' => $sessions,
357                'current_session' => session()->getId(),
358            ],
359        );
360    }
361
362    public function sessionsDelete(Request $request): Response
363    {
364        $id = $request->postData('id');
365        $query = $this->auth->user()
366            ->sessions()
367            ->getQuery()
368            ->where('id', '!=', session()->getId());
369
370        if ($id != 'all') {
371            $this->validate($request, [
372                'id' => 'required|alnum|length:15:15',
373            ]);
374            $query = $query->where('id', 'LIKE', $id . '%');
375        }
376
377        $query->delete();
378        $this->addNotification('settings.sessions.delete_success');
379
380        return $this->redirect->to('/settings/sessions');
381    }
382
383    public function settingsMenu(): array
384    {
385        $menu = [
386            url('/users', ['action' => 'view']) => ['title' => 'profile.my_shifts', 'icon' => 'chevron-left'],
387            url('/settings/profile')  => 'settings.profile',
388            url('/settings/password') => ['title' => 'settings.password', 'icon' => 'key-fill'],
389        ];
390
391        if (count(config('locales')) > 1) {
392            $menu[url('/settings/language')] = ['title' => 'settings.language', 'icon' => 'translate'];
393        }
394
395        if (count(config('themes')) > 1) {
396            $menu[url('/settings/theme')] = 'settings.theme';
397        }
398
399        if (
400            (config('ifsg_enabled') && $this->checkIfsgCertificate())
401            || (config('driving_license_enabled') && $this->checkDrivingLicense())
402        ) {
403            $menu[url('/settings/certificates')] = ['title' => 'settings.certificates', 'icon' => 'card-checklist'];
404        }
405
406        $menu[url('/settings/sessions')] = 'settings.sessions';
407
408        if (!empty(config('oauth'))) {
409            $menu[url('/settings/oauth')] = ['title' => 'settings.oauth', 'hidden' => $this->checkOauthHidden()];
410        }
411
412        if ($this->auth->canAny(['api', 'shifts_json_export', 'ical', 'atom'])) {
413            $menu[url('/settings/api')] = ['title' => 'settings.api', 'icon' => 'braces'];
414        }
415
416        return $menu;
417    }
418
419    protected function checkOauthHidden(): bool
420    {
421        $userServices = $this->auth->user()->oauth;
422        foreach (config('oauth') as $name => $config) {
423            if (empty($config['hidden']) || $userServices->contains('provider', $name)) {
424                return false;
425            }
426        }
427
428        return true;
429    }
430
431    protected function checkDrivingLicense(): bool
432    {
433        return $this->auth->user()->userAngelTypes->filter(function (AngelType $angelType) {
434            return $angelType->requires_driver_license;
435        })->isNotEmpty();
436    }
437
438    protected function checkIfsgCertificate(): bool
439    {
440        return $this->auth->user()->userAngelTypes->filter(function (AngelType $angelType) {
441            return $angelType->requires_ifsg_certificate;
442        })->isNotEmpty();
443    }
444
445    private function isRequired(string $key): string
446    {
447        $requiredFields = $this->config->get('required_user_fields');
448        return in_array($key, $requiredFields) ? 'required' : 'optional';
449    }
450
451    /**
452     * @return string[]
453     */
454    private function getSaveProfileRules(User $user): array
455    {
456        $goodie_tshirt = $this->config->get('goodie_type') === GoodieType::Tshirt->value;
457        $rules = [
458            'pronoun' => $this->isRequired('pronoun') . '|max:15',
459            'first_name' => $this->isRequired('firstname') . '|max:64',
460            'last_name' => $this->isRequired('lastname') . '|max:64',
461            'dect' => $this->isRequired('dect') . '|length:0:40',
462            // dect/mobile can be purely numbers. "max" would have checked their values, not their character length.
463            'mobile' => $this->isRequired('mobile') . '|length:0:40',
464            'mobile_show' => 'optional|checked',
465            'email' => 'required|email|max:254',
466            'email_shiftinfo' => 'optional|checked',
467            'email_news' => 'optional|checked',
468            'email_human' => 'optional|checked',
469            'email_messages' => 'optional|checked',
470            'email_goodie' => 'optional|checked',
471        ];
472        if (config('enable_planned_arrival')) {
473            $rules['planned_arrival_date'] = 'required|date:Y-m-d';
474            $rules['planned_departure_date'] = 'optional|date:Y-m-d';
475        }
476        if ($goodie_tshirt && !$user->state->got_goodie) {
477            $rules['shirt_size'] = $this->isRequired('tshirt_size') . '|shirt_size';
478        }
479        return $rules;
480    }
481}