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
20
 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%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 language
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 saveLanguage
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 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%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 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' => $requiredFields['pronoun'],
54                'isFirstnameRequired' => $requiredFields['firstname'],
55                'isLastnameRequired' => $requiredFields['lastname'],
56                'isTShirtSizeRequired' => $requiredFields['tshirt_size'],
57                'isMobileRequired' => $requiredFields['mobile'],
58                'isDectRequired' => $requiredFields['dect'],
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            && isset(config('tshirt_sizes')[$data['shirt_size'] ?? ''])
116            && !$user->state->got_goodie
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|min:' . $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']);
194        $selectTheme = $data['select_theme'];
195
196        if (!isset(config('themes')[$selectTheme])) {
197            throw new HttpNotFound('Theme with id ' . $selectTheme . ' does not exist.');
198        }
199
200        $user->settings->theme = $selectTheme;
201        $user->settings->save();
202
203        $this->addNotification('settings.theme.success');
204
205        return $this->redirect->to('/settings/theme');
206    }
207
208    public function language(): Response
209    {
210        $languages = config('locales');
211
212        $currentLanguage = $this->auth->user()->settings->language;
213
214        return $this->response->withView(
215            'pages/settings/language',
216            [
217                'settings_menu'    => $this->settingsMenu(),
218                'languages'        => $languages,
219                'current_language' => $currentLanguage,
220            ]
221        );
222    }
223
224    public function saveLanguage(Request $request): Response
225    {
226        $user = $this->auth->user();
227        $data = $this->validate($request, ['select_language' => 'required']);
228        $selectLanguage = $data['select_language'];
229
230        if (!isset(config('locales')[$selectLanguage])) {
231            throw new HttpNotFound('Language ' . $selectLanguage . ' does not exist.');
232        }
233
234        $user->settings->language = $selectLanguage;
235        $user->settings->save();
236
237        session()->set('locale', $selectLanguage);
238
239        $this->addNotification('settings.language.success');
240
241        return $this->redirect->to('/settings/language');
242    }
243
244    public function certificate(): Response
245    {
246        if (
247            !(config('ifsg_enabled') && $this->checkIfsgCertificate())
248            && !(config('driving_license_enabled') && $this->checkDrivingLicense())
249        ) {
250            throw new HttpNotFound();
251        }
252
253        $user = $this->auth->user();
254        return $this->response->withView(
255            'pages/settings/certificates',
256            [
257                'settings_menu' => $this->settingsMenu(),
258                'driving_license' => $this->checkDrivingLicense(),
259                'ifsg' => $this->checkIfsgCertificate(),
260                'certificates' => $user->license,
261            ]
262        );
263    }
264
265    public function saveIfsgCertificate(Request $request): Response
266    {
267        $user = $this->auth->user();
268        if (!config('ifsg_enabled') || $user->license->ifsg_confirmed || !$this->checkIfsgCertificate()) {
269            throw new HttpNotFound();
270        }
271
272        $data = $this->validate($request, [
273            'ifsg_certificate_light' => 'optional|checked',
274            'ifsg_certificate' => 'optional|checked',
275        ]);
276
277        if (config('ifsg_light_enabled')) {
278            $user->license->ifsg_certificate_light = !$data['ifsg_certificate'] && $data['ifsg_certificate_light'];
279        }
280        $user->license->ifsg_certificate = (bool) $data['ifsg_certificate'];
281
282        $user->license->save();
283        $this->addNotification('settings.certificates.success');
284
285        return $this->redirect->to('/settings/certificates');
286    }
287
288    public function saveDrivingLicense(Request $request): Response
289    {
290        if (!config('driving_license_enabled') || !$this->checkDrivingLicense()) {
291            throw new HttpNotFound();
292        }
293
294        $user = $this->auth->user();
295        $data = $this->validate($request, [
296            'has_car' => 'optional|checked',
297            'drive_car' => 'optional|checked',
298            'drive_3_5t' => 'optional|checked',
299            'drive_7_5t' => 'optional|checked',
300            'drive_12t' => 'optional|checked',
301            'drive_forklift' => 'optional|checked',
302        ]);
303
304        $user->license->has_car = (bool) $data['has_car'];
305        if (!$user->license->drive_confirmed) {
306            $user->license->drive_car = (bool) $data['drive_car'];
307            $user->license->drive_3_5t = (bool) $data['drive_3_5t'];
308            $user->license->drive_7_5t = (bool) $data['drive_7_5t'];
309            $user->license->drive_12t = (bool) $data['drive_12t'];
310            $user->license->drive_forklift = (bool) $data['drive_forklift'];
311        }
312        $user->license->save();
313
314        $this->addNotification('settings.certificates.success');
315
316        return $this->redirect->to('/settings/certificates');
317    }
318
319    public function api(): Response
320    {
321        return $this->response->withView(
322            'pages/settings/api',
323            [
324                'settings_menu' => $this->settingsMenu(),
325            ],
326        );
327    }
328
329    public function apiKeyReset(): Response
330    {
331        $this->auth->resetApiKey($this->auth->user());
332
333        $this->addNotification('settings.api.key_reset_success');
334        return $this->redirect->back();
335    }
336
337    public function oauth(): Response
338    {
339        $providers = $this->config->get('oauth');
340        if (empty($providers)) {
341            throw new HttpNotFound();
342        }
343
344        return $this->response->withView(
345            'pages/settings/oauth',
346            [
347                'settings_menu' => $this->settingsMenu(),
348                'providers'     => $providers,
349            ],
350        );
351    }
352
353    public function sessions(): Response
354    {
355        $sessions = $this->auth->user()->sessions->sortByDesc('last_activity');
356
357        return $this->response->withView(
358            'pages/settings/sessions',
359            [
360                'settings_menu' => $this->settingsMenu(),
361                'sessions' => $sessions,
362                'current_session' => session()->getId(),
363            ],
364        );
365    }
366
367    public function sessionsDelete(Request $request): Response
368    {
369        $id = $request->postData('id');
370        $query = $this->auth->user()
371            ->sessions()
372            ->getQuery()
373            ->where('id', '!=', session()->getId());
374
375        if ($id != 'all') {
376            $this->validate($request, [
377                'id' => 'required|alnum|length:15:15',
378            ]);
379            $query = $query->where('id', 'LIKE', $id . '%');
380        }
381
382        $query->delete();
383        $this->addNotification('settings.sessions.delete_success');
384
385        return $this->redirect->to('/settings/sessions');
386    }
387
388    public function settingsMenu(): array
389    {
390        $menu = [
391            url('/users', ['action' => 'view']) => ['title' => 'profile.my_shifts', 'icon' => 'chevron-left'],
392            url('/settings/profile')  => 'settings.profile',
393            url('/settings/password') => ['title' => 'settings.password', 'icon' => 'key-fill'],
394        ];
395
396        if (count(config('locales')) > 1) {
397            $menu[url('/settings/language')] = ['title' => 'settings.language', 'icon' => 'translate'];
398        }
399
400        if (count(config('themes')) > 1) {
401            $menu[url('/settings/theme')] = 'settings.theme';
402        }
403
404        if (
405            (config('ifsg_enabled') && $this->checkIfsgCertificate())
406            || (config('driving_license_enabled') && $this->checkDrivingLicense())
407        ) {
408            $menu[url('/settings/certificates')] = ['title' => 'settings.certificates', 'icon' => 'card-checklist'];
409        }
410
411        $menu[url('/settings/sessions')] = 'settings.sessions';
412
413        if (!empty(config('oauth'))) {
414            $menu[url('/settings/oauth')] = ['title' => 'settings.oauth', 'hidden' => $this->checkOauthHidden()];
415        }
416
417        if ($this->auth->canAny(['api', 'shifts_json_export', 'ical', 'atom'])) {
418            $menu[url('/settings/api')] = ['title' => 'settings.api', 'icon' => 'braces'];
419        }
420
421        return $menu;
422    }
423
424    protected function checkOauthHidden(): bool
425    {
426        foreach (config('oauth') as $config) {
427            if (empty($config['hidden'])) {
428                return false;
429            }
430        }
431
432        return true;
433    }
434
435    protected function checkDrivingLicense(): bool
436    {
437        return $this->auth->user()->userAngelTypes->filter(function (AngelType $angelType) {
438            return $angelType->requires_driver_license;
439        })->isNotEmpty();
440    }
441
442    protected function checkIfsgCertificate(): bool
443    {
444        return $this->auth->user()->userAngelTypes->filter(function (AngelType $angelType) {
445            return $angelType->requires_ifsg_certificate;
446        })->isNotEmpty();
447    }
448
449    private function isRequired(string $key): string
450    {
451        $requiredFields = $this->config->get('required_user_fields');
452        return $requiredFields[$key] ? 'required' : 'optional';
453    }
454
455    /**
456     * @return string[]
457     */
458    private function getSaveProfileRules(User $user): array
459    {
460        $goodie_tshirt = $this->config->get('goodie_type') === GoodieType::Tshirt->value;
461        $rules = [
462            'pronoun' => $this->isRequired('pronoun') . '|max:15',
463            'first_name' => $this->isRequired('firstname') . '|max:64',
464            'last_name' => $this->isRequired('lastname') . '|max:64',
465            'dect' => $this->isRequired('dect') . '|length:0:40',
466            // dect/mobile can be purely numbers. "max" would have checked their values, not their character length.
467            'mobile' => $this->isRequired('mobile') . '|length:0:40',
468            'mobile_show' => 'optional|checked',
469            'email' => 'required|email|max:254',
470            'email_shiftinfo' => 'optional|checked',
471            'email_news' => 'optional|checked',
472            'email_human' => 'optional|checked',
473            'email_messages' => 'optional|checked',
474            'email_goodie' => 'optional|checked',
475        ];
476        if (config('enable_planned_arrival')) {
477            $rules['planned_arrival_date'] = 'required|date:Y-m-d';
478            $rules['planned_departure_date'] = 'optional|date:Y-m-d';
479        }
480        if ($goodie_tshirt && !$user->state->got_goodie) {
481            $rules['shirt_size'] = $this->isRequired('tshirt_size') . '|shirt_size';
482        }
483        return $rules;
484    }
485}