diff options
78 files changed, 1333 insertions, 462 deletions
diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..60e5c255 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +# This file is for unifying the coding style for different editors and IDEs +# editorconfig.org + +# PHP PSR-2 Coding Standards +# http://www.php-fig.org/psr/psr-2/ + +root = true + +[*.php] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + @@ -24,6 +24,7 @@ _vimrc_local.vim /public/coverage /coverage /unittests.xml +/resources/lang/*/*.mo # Composer files /vendor/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 98ffd902..e14b1841 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -9,6 +9,7 @@ variables: MYSQL_PASSWORD: engelsystem MYSQL_HOST: mariadb MYSQL_RANDOM_ROOT_PASSWORD: "yes" + MYSQL_INITDB_SKIP_TZINFO: "yes" DOCROOT: /var/www/ stages: @@ -58,6 +58,11 @@ The following instructions explain how to get, build and run the latest engelsys ```bash yarn build ``` + * Optionally (for better performance) + * Generate translation files + ```bash + find resources/lang/ -type f -name '*.po' -exec sh -c 'file="{}"; msgfmt "${file%.*}.po" -o "${file%.*}.mo"' \; + ``` ### Configuration and Setup * The webserver must have write access to the ```import``` and ```storage``` directories and read access for all other directories @@ -127,6 +132,20 @@ Import database docker exec -it engelsystem bin/migrate ``` +#### Local development +To use the working directory in the container the docker-compose file has to be changed: +```yaml +[...] + nginx: + volumes: + - ../public/assets:/var/www/public/assets +[...] + engelsystem: + volumes: + - ../:/var/www +[...] +``` + #### Scripts ##### bin/deploy.sh The `bin/deploy.sh` script can be used to deploy the engelsystem. It uses rsync to deploy the application to a server over ssh. diff --git a/config/routes.php b/config/routes.php index 02fd3abd..e57d3079 100644 --- a/config/routes.php +++ b/config/routes.php @@ -13,6 +13,12 @@ $route->get('/login', 'AuthController@login'); $route->post('/login', 'AuthController@postLogin'); $route->get('/logout', 'AuthController@logout'); +// Password recovery +$route->get('/password/reset', 'PasswordResetController@reset'); +$route->post('/password/reset', 'PasswordResetController@postReset'); +$route->get('/password/reset/{token:.+}', 'PasswordResetController@resetPassword'); +$route->post('/password/reset/{token:.+}', 'PasswordResetController@postResetPassword'); + // Stats $route->get('/metrics', 'Metrics\\Controller@metrics'); $route->get('/stats', 'Metrics\\Controller@stats'); diff --git a/contrib/Dockerfile b/contrib/Dockerfile index a7fd403b..013ccf1d 100644 --- a/contrib/Dockerfile +++ b/contrib/Dockerfile @@ -4,7 +4,12 @@ COPY ./ /app/ RUN composer --no-ansi install --no-dev --ignore-platform-reqs RUN composer --no-ansi dump-autoload --optimize -# Intermediate container for less layers +# Intermediate containers for less layers +FROM alpine as translation +RUN apk add gettext +COPY resources/lang/ /data +RUN find /data -type f -name '*.po' -exec sh -c 'file="{}"; msgfmt "${file%.*}.po" -o "${file%.*}.mo"' \; + FROM alpine as data COPY .babelrc .browserslistrc composer.json LICENSE package.json README.md webpack.config.js yarn.lock /app/ COPY bin/ /app/bin @@ -13,11 +18,11 @@ COPY db/ /app/db RUN mkdir /app/import/ COPY includes/ /app/includes COPY public/ /app/public -COPY resources/lang /app/resources/lang COPY resources/views /app/resources/views COPY src/ /app/src COPY storage/ /app/storage +COPY --from=translation /data/ /app/resources/lang COPY --from=composer /app/vendor/ /app/vendor COPY --from=composer /app/composer.lock /app/ diff --git a/contrib/docker-compose.yml b/contrib/docker-compose.yml index b1dab793..4624cce4 100644 --- a/contrib/docker-compose.yml +++ b/contrib/docker-compose.yml @@ -27,19 +27,19 @@ services: depends_on: - database database: - image: mariadb:latest + image: mariadb:10.2 environment: MYSQL_DATABASE: engelsystem MYSQL_USER: engelsystem MYSQL_PASSWORD: engelsystem MYSQL_RANDOM_ROOT_PASSWORD: 1 + MYSQL_INITDB_SKIP_TZINFO: "yes" volumes: - db:/var/lib/mysql networks: - database volumes: db: {} - static: {} networks: internal: diff --git a/includes/controller/shifts_controller.php b/includes/controller/shifts_controller.php index a4d44151..15f92a9d 100644 --- a/includes/controller/shifts_controller.php +++ b/includes/controller/shifts_controller.php @@ -1,5 +1,6 @@ <?php +use Engelsystem\Http\Exceptions\HttpForbidden; use Engelsystem\ShiftSignupState; /** @@ -349,17 +350,18 @@ function shift_next_controller() function shifts_json_export_controller() { $request = request(); + $user = auth()->apiUser('key'); - if (!$request->has('key') || !preg_match('/^[\da-f]{32}$/', $request->input('key'))) { - engelsystem_error('Missing key.'); + if ( + !$request->has('key') + || !preg_match('/^[\da-f]{32}$/', $request->input('key')) + || !$user + ) { + throw new HttpForbidden('{"error":"Missing or invalid key"}', ['content-type' => 'application/json']); } - $user = auth()->apiUser('key'); - if (!$user) { - engelsystem_error('Key invalid.'); - } if (!auth()->can('shifts_json_export')) { - engelsystem_error('No privilege for shifts_json_export.'); + throw new HttpForbidden('{"error":"Not allowed"}', ['content-type' => 'application/json']); } $shifts = load_ical_shifts(); diff --git a/includes/controller/users_controller.php b/includes/controller/users_controller.php index 892089e7..3ad2ffd9 100644 --- a/includes/controller/users_controller.php +++ b/includes/controller/users_controller.php @@ -1,7 +1,6 @@ <?php use Engelsystem\Database\DB; -use Engelsystem\Models\User\PasswordReset; use Engelsystem\Models\User\State; use Engelsystem\Models\User\User; use Engelsystem\ShiftCalendarRenderer; @@ -312,120 +311,6 @@ function users_list_controller() } /** - * Second step of password recovery: set a new password using the token link from email - * - * @return string - */ -function user_password_recovery_set_new_controller() -{ - $request = request(); - $passwordReset = PasswordReset::whereToken($request->input('token'))->first(); - if (!$passwordReset) { - error(__('Token is not correct.')); - redirect(page_link_to('login')); - } - - if ($request->hasPostData('submit')) { - $valid = true; - - if ( - $request->has('password') - && strlen($request->postData('password')) >= config('min_password_length') - ) { - if ($request->postData('password') != $request->postData('password2')) { - $valid = false; - error(__('Your passwords don\'t match.')); - } - } else { - $valid = false; - error(__('Your password is to short (please use at least 6 characters).')); - } - - if ($valid) { - auth()->setPassword($passwordReset->user, $request->postData('password')); - success(__('Password saved.')); - $passwordReset->delete(); - redirect(page_link_to('login')); - } - } - - return User_password_set_view(); -} - -/** - * First step of password recovery: display a form that asks for your email and send email with recovery link - * - * @return string - */ -function user_password_recovery_start_controller() -{ - $request = request(); - if ($request->hasPostData('submit')) { - $valid = true; - - $user_source = null; - if ($request->has('email') && strlen(strip_request_item('email')) > 0) { - $email = strip_request_item('email'); - if (check_email($email)) { - /** @var User $user_source */ - $user_source = User::whereEmail($email)->first(); - if (!$user_source) { - $valid = false; - error(__('E-mail address is not correct.')); - } - } else { - $valid = false; - error(__('E-mail address is not correct.')); - } - } else { - $valid = false; - error(__('Please enter your e-mail.')); - } - - if ($valid) { - $token = User_generate_password_recovery_token($user_source); - engelsystem_email_to_user( - $user_source, - __('Password recovery'), - sprintf( - __('Please visit %s to recover your password.'), - page_link_to('user_password_recovery', ['token' => $token]) - ) - ); - success(__('We sent an email containing your password recovery link.')); - redirect(page_link_to('login')); - } - } - - return User_password_recovery_view(); -} - -/** - * User password recovery in 2 steps. - * (By email) - * - * @return string - */ -function user_password_recovery_controller() -{ - if (request()->has('token')) { - return user_password_recovery_set_new_controller(); - } - - return user_password_recovery_start_controller(); -} - -/** - * Menu title for password recovery. - * - * @return string - */ -function user_password_recovery_title() -{ - return __('Password recovery'); -} - -/** * Loads a user from param user_id. * * @return User diff --git a/includes/helper/error_helper.php b/includes/helper/error_helper.php deleted file mode 100644 index 9314a57a..00000000 --- a/includes/helper/error_helper.php +++ /dev/null @@ -1,11 +0,0 @@ -<?php - -/** - * Displays a fatal message and stops execution. - * - * @param string $message - */ -function engelsystem_error($message) -{ - raw_output($message); -} diff --git a/includes/includes.php b/includes/includes.php index 855ff359..601a6ca2 100644 --- a/includes/includes.php +++ b/includes/includes.php @@ -60,7 +60,6 @@ $includeFiles = [ __DIR__ . '/../includes/helper/graph_helper.php', __DIR__ . '/../includes/helper/message_helper.php', - __DIR__ . '/../includes/helper/error_helper.php', __DIR__ . '/../includes/helper/email_helper.php', __DIR__ . '/../includes/mailer/shifts_mailer.php', diff --git a/includes/model/User_model.php b/includes/model/User_model.php index 1994bc47..681e70aa 100644 --- a/includes/model/User_model.php +++ b/includes/model/User_model.php @@ -2,7 +2,6 @@ use Carbon\Carbon; use Engelsystem\Database\DB; -use Engelsystem\Models\User\PasswordReset; use Engelsystem\Models\User\User; use Engelsystem\ValidationResult; use Illuminate\Database\Query\JoinClause; @@ -228,24 +227,6 @@ function User_reset_api_key($user, $log = true) } /** - * Generates a new password recovery token for given user. - * - * @param User $user - * @return string - */ -function User_generate_password_recovery_token($user) -{ - $reset = PasswordReset::findOrNew($user->id); - $reset->user_id = $user->id; - $reset->token = md5($user->name . time() . rand()); - $reset->save(); - - engelsystem_log('Password recovery for ' . User_Nick_render($user, true) . ' started.'); - - return $reset->token; -} - -/** * @param User $user * @return float */ diff --git a/includes/pages/guest_login.php b/includes/pages/guest_login.php index 280743e5..170572e4 100644 --- a/includes/pages/guest_login.php +++ b/includes/pages/guest_login.php @@ -242,9 +242,9 @@ function guest_register() redirect(page_link_to('register')); } - // If a welcome message is present, display registration success page. + // If a welcome message is present, display it on the next page if ($message = $config->get('welcome_msg')) { - return User_registration_success_view($message); + info((new Parsedown())->text($message)); } redirect(page_link_to('/')); diff --git a/includes/pages/user_atom.php b/includes/pages/user_atom.php index 8e5b4858..a491fea7 100644 --- a/includes/pages/user_atom.php +++ b/includes/pages/user_atom.php @@ -1,6 +1,7 @@ <?php use Engelsystem\Database\DB; +use Engelsystem\Http\Exceptions\HttpForbidden; /** * Publically available page to feed the news to feed readers @@ -8,17 +9,18 @@ use Engelsystem\Database\DB; function user_atom() { $request = request(); + $user = auth()->apiUser('key'); - if (!$request->has('key') || !preg_match('/^[\da-f]{32}$/', $request->input('key'))) { - engelsystem_error('Missing key.'); + if ( + !$request->has('key') + || !preg_match('/^[\da-f]{32}$/', $request->input('key')) + || empty($user) + ) { + throw new HttpForbidden('Missing or invalid key', ['content-type' => 'text/text']); } - $user = auth()->apiUser('key'); - if (empty($user)) { - engelsystem_error('Key invalid.'); - } if (!auth()->can('atom')) { - engelsystem_error('No privilege for atom.'); + throw new HttpForbidden('Not allowed', ['content-type' => 'text/text']); } $news = DB::select(' diff --git a/includes/pages/user_ical.php b/includes/pages/user_ical.php index ee3a8340..2f3a7ccc 100644 --- a/includes/pages/user_ical.php +++ b/includes/pages/user_ical.php @@ -1,22 +1,25 @@ <?php +use Engelsystem\Http\Exceptions\HttpForbidden; + /** * Controller for ical output of users own shifts or any user_shifts filter. */ function user_ical() { $request = request(); + $user = auth()->apiUser('key'); - if (!$request->has('key') || !preg_match('/^[\da-f]{32}$/', $request->input('key'))) { - engelsystem_error('Missing key.'); + if ( + !$request->has('key') + || !preg_match('/^[\da-f]{32}$/', $request->input('key')) + || !$user + ) { + throw new HttpForbidden('Missing or invalid key', ['content-type' => 'text/text']); } - $user = auth()->apiUser('key'); - if (!$user) { - engelsystem_error('Key invalid.'); - } if (!auth()->can('ical')) { - engelsystem_error('No privilege for ical.'); + throw new HttpForbidden('Not allowed', ['content-type' => 'text/text']); } $ical_shifts = load_ical_shifts(); diff --git a/includes/view/ShiftCalendarShiftRenderer.php b/includes/view/ShiftCalendarShiftRenderer.php index 1414c351..4911979f 100644 --- a/includes/view/ShiftCalendarShiftRenderer.php +++ b/includes/view/ShiftCalendarShiftRenderer.php @@ -38,23 +38,23 @@ class ShiftCalendarShiftRenderer return [ $blocks, - div( - 'shift panel panel-' . $class . '" ' - . 'style="height: ' + div('shift-card" style="height: ' . ($blocks * ShiftCalendarRenderer::BLOCK_HEIGHT - ShiftCalendarRenderer::MARGIN) - . 'px"', - [ - $this->renderShiftHead($shift, $class), - div('panel-body', [ - $info_text, - Room_name_render([ - 'RID' => $shift['RID'], - 'Name' => $shift['room_name'] - ]) - ]), - $shifts_row, - div('shift-spacer') - ] + . 'px;', + div( + 'shift panel panel-' . $class, + [ + $this->renderShiftHead($shift, $class), + div('panel-body', [ + $info_text, + Room_name_render([ + 'RID' => $shift['RID'], + 'Name' => $shift['room_name'] + ]) + ]), + $shifts_row + ] + ) ) ]; } diff --git a/includes/view/User_view.php b/includes/view/User_view.php index bfe7e02c..95ecb626 100644 --- a/includes/view/User_view.php +++ b/includes/view/User_view.php @@ -108,46 +108,6 @@ function User_settings_view( } /** - * Displays the welcome message to the user and shows a login form. - * - * @param string $event_welcome_message - * @return string - */ -function User_registration_success_view($event_welcome_message) -{ - $parsedown = new Parsedown(); - $event_welcome_message = $parsedown->text($event_welcome_message); - - return page_with_title(__('Registration successful'), [ - msg(), - div('row', [ - div('col-md-4', [ - $event_welcome_message - ]), - div('col-md-4', [ - '<h2>' . __('Login') . '</h2>', - form([ - form_text('login', __('Nick'), ''), - form_password('password', __('Password')), - form_submit('submit', __('Login')), - buttons([ - button(page_link_to('user_password_recovery'), __('I forgot my password')) - ]), - info(__('Please note: You have to activate cookies!'), true) - ], page_link_to('login')) - ]), - div('col-md-4', [ - '<h2>' . __('What can I do?') . '</h2>', - '<p>' . __('Please read about the jobs you can do to help us.') . '</p>', - buttons([ - button(page_link_to('angeltypes', ['action' => 'about']), __('Teams/Job description') . ' »') - ]) - ]) - ]) - ]); -} - -/** * Gui for deleting user with password field. * * @param User $user @@ -255,13 +215,13 @@ function Users_view( ]; $user_table_headers = [ - 'name' => Users_table_header_link('name', __('Nick'), $order_by) + 'name' => Users_table_header_link('name', __('Nick'), $order_by) ]; - if(config('enable_user_name')) { + if (config('enable_user_name')) { $user_table_headers['first_name'] = Users_table_header_link('first_name', __('Prename'), $order_by); $user_table_headers['last_name'] = Users_table_header_link('last_name', __('Name'), $order_by); } - if(config('enable_dect')) { + if (config('enable_dect')) { $user_table_headers['dect'] = Users_table_header_link('dect', __('DECT'), $order_by); } $user_table_headers['arrived'] = Users_table_header_link('arrived', __('Arrived'), $order_by); @@ -271,8 +231,16 @@ function Users_view( $user_table_headers['force_active'] = Users_table_header_link('force_active', __('Forced'), $order_by); $user_table_headers['got_shirt'] = Users_table_header_link('got_shirt', __('T-Shirt'), $order_by); $user_table_headers['shirt_size'] = Users_table_header_link('shirt_size', __('Size'), $order_by); - $user_table_headers['arrival_date'] = Users_table_header_link('planned_arrival_date', __('Planned arrival'), $order_by); - $user_table_headers['departure_date'] = Users_table_header_link('planned_departure_date', __('Planned departure'), $order_by); + $user_table_headers['arrival_date'] = Users_table_header_link( + 'planned_arrival_date', + __('Planned arrival'), + $order_by + ); + $user_table_headers['departure_date'] = Users_table_header_link( + 'planned_departure_date', + __('Planned departure'), + $order_by + ); $user_table_headers['last_login_at'] = Users_table_header_link('last_login_at', __('Last login'), $order_by); $user_table_headers['actions'] = ''; @@ -792,41 +760,6 @@ function User_view_state_admin($freeloader, $user_source) } /** - * View for password recovery step 1: E-Mail - * - * @return string - */ -function User_password_recovery_view() -{ - return page_with_title(user_password_recovery_title(), [ - msg(), - __('We will send you an e-mail with a password recovery link. Please use the email address you used for registration.'), - form([ - form_text('email', __('E-Mail'), ''), - form_submit('submit', __('Recover')) - ]) - ]); -} - -/** - * View for password recovery step 2: New password - * - * @return string - */ -function User_password_set_view() -{ - return page_with_title(user_password_recovery_title(), [ - msg(), - __('Please enter a new password.'), - form([ - form_password('password', __('Password')), - form_password('password2', __('Confirm password')), - form_submit('submit', __('Save')) - ]) - ]); -} - -/** * @param array[] $user_angeltypes * @return string */ diff --git a/package.json b/package.json index 5a8a5a44..8a623a3b 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "eonasdan-bootstrap-datetimepicker": "^4.17.47", "jquery": "^3.3.1", "jquery-ui": "^1.11.2", - "moment": "^2.8.2", + "moment": "^2.12.0", "moment-timezone": "^0.4.0", "select2": "^4.0.6-rc.1", "select2-bootstrap-theme": "0.1.0-beta.10" diff --git a/resources/assets/js/forms.js b/resources/assets/js/forms.js index f5818e97..9970b907 100644 --- a/resources/assets/js/forms.js +++ b/resources/assets/js/forms.js @@ -16,7 +16,7 @@ global.checkAll = (id, checked) => { * Sets the checkboxes according to the given type * * @param {string} id The elements ID - * @param {list} shifts_list A list of numbers + * @param {list} shiftsList A list of numbers */ global.checkOwnTypes = (id, shiftsList) => { $('#' + id + ' input[type="checkbox"]').each(function () { @@ -144,10 +144,10 @@ $(function () { elem.children('input').on('click', function (ev) { ev.stopImmediatePropagation(); if (typeof elem.data('DateTimePicker') === 'undefined') { - elem.datetimepicker(opts); - elem.data('DateTimePicker').show(); + elem.datetimepicker(opts); + elem.data('DateTimePicker').show(); } else { - elem.data('DateTimePicker').toggle(); + elem.data('DateTimePicker').toggle(); } }); }); @@ -173,3 +173,30 @@ $(function () { }); }); }); + +/** + * Set the filter selects to latest state + * + * Uses DOMContentLoaded to prevent flickering + */ +window.addEventListener('DOMContentLoaded', () => { + const filter = document.getElementById('collapseShiftsFilterSelect'); + if (!filter || localStorage.getItem('collapseShiftsFilterSelect') !== 'hidden') { + return; + } + + filter.classList.remove('in'); +}); +$(() => { + if (typeof (localStorage) === 'undefined') { + return; + } + + const onChange = (e) => { + localStorage.setItem('collapseShiftsFilterSelect', e.type); + }; + + $('#collapseShiftsFilterSelect') + .on('hidden.bs.collapse', onChange) + .on('shown.bs.collapse', onChange); +}); diff --git a/resources/assets/js/vendor.js b/resources/assets/js/vendor.js index bf3807f7..b4b6487d 100644 --- a/resources/assets/js/vendor.js +++ b/resources/assets/js/vendor.js @@ -10,6 +10,13 @@ require('./forms'); require('./sticky-headers'); require('./moment-countdown'); +moment.updateLocale('en', { + week : { + dow : 1, // Monday is the first day of the week. + doy : 4 // The week that contains Jan 4th is the first week of the year. + } +}); + $.ajaxSetup({ headers: {'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')} }); diff --git a/resources/assets/themes/base.less b/resources/assets/themes/base.less index b44602f0..74dadc11 100644 --- a/resources/assets/themes/base.less +++ b/resources/assets/themes/base.less @@ -11,7 +11,7 @@ body { } .first { - clear: both; + clear: both; margin-top: 30px; } @@ -52,7 +52,7 @@ table a > .icon-icon_angel { } .table .form-group { - margin-bottom: 0; + margin-bottom: 0; } .stats { @@ -60,20 +60,22 @@ table a > .icon-icon_angel { .make-xs-column(6); font-size: 20px; height: 150px; - text-align: center; - - .number { - font-size: 80px; - font-weight: 200; - } - @media (max-width: @screen-md) { - font-size: inherit; - .number { - font-size: 40px; - } + text-align: center; + + .number { + font-size: 80px; + font-weight: 200; + } + + @media (max-width: @screen-md) { + font-size: inherit; + .number { + font-size: 40px; + } } } + .stats-danger { color: @brand-danger; } @@ -87,12 +89,12 @@ table a > .icon-icon_angel { } .dashboard-panel { - position: relative; - font-size: 20px; + position: relative; + font-size: 20px; color: @headings-color; - .panel-link { - position: absolute; + .panel-link { + position: absolute; width: 100%; height: 100%; top: 0; @@ -102,11 +104,11 @@ table a > .icon-icon_angel { background-color: @brand-primary; /* Fix to make div clickable in IE */ opacity: 0; /* Fix to make div clickable in IE */ filter: alpha(opacity=1); /* Fix to make div clickable in IE */ - } + } - .panel-link:hover { + .panel-link:hover { opacity: 0.3; - } + } } .panel-primary .panel-heading a { @@ -140,7 +142,7 @@ table a > .icon-icon_angel { } .selection .checkbox { - display: block; + display: block; } .shift-calendar { @@ -190,9 +192,20 @@ table a > .icon-icon_angel { flex-shrink: 0; } - .shift { - margin: 0 5px 5px 0; + .shift-card { + z-index: 0; overflow: hidden; + position: relative; + margin: 0 5px 5px 0; + + &:hover { + overflow: visible; + z-index: 100; + } + + .shift { + min-height: 100%; + } } } @@ -201,13 +214,14 @@ table a > .icon-icon_angel { } .column_duration { - text-align: right; + text-align: right; } .messages { &:focus { outline: none; } + a:focus { outline: none; } @@ -296,7 +310,9 @@ table a > .icon-icon_angel { float: none; width: 100%; - position: relative; left: 0; right: 0; + position: relative; + left: 0; + right: 0; min-height: 1px; padding-right: 15px; padding-left: 15px; diff --git a/resources/lang/de_DE/default.po b/resources/lang/de_DE/default.po index 3c1e0d15..8dba9b4b 100644 --- a/resources/lang/de_DE/default.po +++ b/resources/lang/de_DE/default.po @@ -619,9 +619,9 @@ msgid "Please visit %s to recover your password." msgstr "Bitte besuche %s, um Dein Passwort zurückzusetzen" #: includes/controller/users_controller.php:394 -msgid "We sent an email containing your password recovery link." +msgid "We sent you an email containing your password recovery link." msgstr "" -"Wir haben eine eMail mit einem Link zum Passwort-zurücksetzen geschickt." +"Wir haben dir eine eMail mit einem Link zum Passwort-zurücksetzen geschickt." #: includes/helper/email_helper.php:41 #, php-format @@ -2772,3 +2772,21 @@ msgstr "Bitte gib ein Passwort an." msgid "validation.login.required" msgstr "Bitte gib einen Loginnamen an." + +msgid "form.submit" +msgstr "Absenden" + +msgid "validation.email.required" +msgstr "Bitte gib eine E-Mail-Adresse an." + +msgid "validation.email.email" +msgstr "Die E-Mail-Adresse ist nicht gültig." + +msgid "validation.password.min" +msgstr "Dein angegebenes Passwort ist zu kurz." + +msgid "validation.password.confirmed" +msgstr "Deine Passwörter stimmen nicht überein." + +msgid "validation.password_confirmation.required" +msgstr "Du musst dein Passwort bestätigen." diff --git a/resources/lang/en_US/default.po b/resources/lang/en_US/default.po index 1ff16c83..a2d56fd1 100644 --- a/resources/lang/en_US/default.po +++ b/resources/lang/en_US/default.po @@ -30,3 +30,21 @@ msgstr "The password is required." msgid "validation.login.required" msgstr "The login name is required." + +msgid "form.submit" +msgstr "Submit" + +msgid "validation.email.required" +msgstr "The email address is required." + +msgid "validation.email.email" +msgstr "This email address is not valid." + +msgid "validation.password.min" +msgstr "Your password is too short." + +msgid "validation.password.confirmed" +msgstr "Your passwords are not equal." + +msgid "validation.password_confirmation.required" +msgstr "You have to confirm your password." diff --git a/resources/lang/pt_BR/default.po b/resources/lang/pt_BR/default.po index b9bf420d..e5e5371e 100644 --- a/resources/lang/pt_BR/default.po +++ b/resources/lang/pt_BR/default.po @@ -551,7 +551,7 @@ msgid "Please visit %s to recover your password." msgstr "Por favor visite %s para recuperar sua senha" #: includes/controller/users_controller.php:271 -msgid "We sent an email containing your password recovery link." +msgid "We sent you an email containing your password recovery link." msgstr "Nós enviamos um email com o link para recuperação da sua senha." #: includes/helper/email_helper.php:12 diff --git a/resources/views/emails/mail.twig b/resources/views/emails/mail.twig index ec70f594..e0ad7b64 100644 --- a/resources/views/emails/mail.twig +++ b/resources/views/emails/mail.twig @@ -1,6 +1,6 @@ -{{ __('Hi %s,', [username]) }} +{% block title %}{{ __('Hi %s,', [username]) }}{% endblock %} -{{ __('here is a message for you from the %s:', [config('app_name')]) }} -{{ message|raw }} +{% block introduction %}{{ __('here is a message for you from the %s:', [config('app_name')]) }}{% endblock %} +{% block message %}{{ message|raw }}{% endblock %} -{{ __('This email is autogenerated and has not been signed. You got this email because you are registered in the %s.', [config('app_name')]) }} +{% block footer %}{{ __('This email is autogenerated and has not been signed. You got this email because you are registered in the %s.', [config('app_name')]) }}{% endblock %} diff --git a/resources/views/emails/password-reset.twig b/resources/views/emails/password-reset.twig new file mode 100644 index 00000000..30b613b4 --- /dev/null +++ b/resources/views/emails/password-reset.twig @@ -0,0 +1,3 @@ +{% extends "emails/mail.twig" %} + +{% block message %}{{ __('Please visit %s to recover your password.', [url('/password/reset/') ~ reset.token]) }}{% endblock %} diff --git a/resources/views/layouts/parts/navbar.twig b/resources/views/layouts/parts/navbar.twig index 61c6a10b..0b2eee63 100644 --- a/resources/views/layouts/parts/navbar.twig +++ b/resources/views/layouts/parts/navbar.twig @@ -55,16 +55,14 @@ {{ elements.toolbar_item(user.name, url('users', {'action': 'view'}), 'users', 'icon icon-icon_angel') }} {% endif %} - {% if has_permission_to('user_settings') or has_permission_to('logout') %} - <li class="dropdown"> - <a href="#" class="dropdown-toggle" data-toggle="dropdown"> - <span class="caret"></span> - </a> - <ul class="dropdown-menu" role="menu"> - {{ menuUserSubmenu()|join(" ")|raw }} - </ul> - </li> - {% endif %} + <li class="dropdown"> + <a href="#" class="dropdown-toggle" data-toggle="dropdown"> + <span class="caret"></span> + </a> + <ul class="dropdown-menu" role="menu"> + {{ menuUserSubmenu()|join(" ")|raw }} + </ul> + </li> </ul> {% endblock %} diff --git a/resources/views/macros/form.twig b/resources/views/macros/form.twig new file mode 100644 index 00000000..5d41b085 --- /dev/null +++ b/resources/views/macros/form.twig @@ -0,0 +1,18 @@ +{% macro input(name, label, type, required) %} + <div class="form-group"> + {% if label %} + <label for="{{ name }}">{{ label }}</label> + {% endif %} + <input type="{{ type|default('text') }}" class="form-control" id="{{ name }}" name="{{ name }}" + {%- if required|default(false) %} required="required"{% endif -%} + > + </div> +{% endmacro %} + +{% macro hidden(name, value) %} + <input type="hidden" id="{{ name }}" name="{{ name }}" value="{{ value }}"> +{% endmacro %} + +{% macro submit(label) %} + <button type="submit" class="btn btn-default">{{ label|default(__('form.submit')) }}</button> +{% endmacro %} diff --git a/resources/views/pages/credits.twig b/resources/views/pages/credits.twig index 3bb04895..8bd1f694 100644 --- a/resources/views/pages/credits.twig +++ b/resources/views/pages/credits.twig @@ -4,28 +4,28 @@ {% block content %} <div class="container"> - <h1>Credits</h1> + <h1>{{ __('Credits') }}</h1> <div class="row"> {% for title, credit in credits %} <div class="col-md-4"> - <h2>{{ title }}</h2> - {{ credit|markdown }} + <h2>{{ __(title) }}</h2> + {{ __(credit)|markdown }} </div> {% endfor %} <div class="col-md-4"> - <h2>Source code</h2> - <p>Version: <i>{{ version }}</i></p> + <h2>{{ __('Source code') }}</h2> + <p>{{ __('Version: _%s_', [version])|markdown }}</i></p> <p> - The original engelsystem was written by - <a href="https://github.com/cookieBerlin/engelsystem">cookie</a>. - It was then completely rewritten and enhanced by - <a href="https://notrademark.de">msquare</a> (maintainer) and - <a href="https://myigel.name">MyIgel</a>. + {{ __('The original engelsystem was written by +[cookie](https://github.com/cookieBerlin/engelsystem). +It was then completely rewritten and enhanced by [msquare](https://notrademark.de) (maintainer) and +[MyIgel](https://myigel.name).')|markdown }} </p> <p> - Please look at the <a href="https://github.com/engelsystem/engelsystem/graphs/contributors"> - contributor list on GitHub</a> for a complete list. + {{ __('Please have a look at the +[contributors list on GitHub](https://github.com/engelsystem/engelsystem/graphs/contributors) +for a complete list.')|markdown }} </p> </div> </div> diff --git a/resources/views/pages/login.twig b/resources/views/pages/login.twig index da6f4fdf..34dbd63f 100644 --- a/resources/views/pages/login.twig +++ b/resources/views/pages/login.twig @@ -32,6 +32,7 @@ <div class="col-sm-6 col-sm-offset-3 col-md-4 col-md-offset-4"> <div class="panel panel-primary first"> <div class="panel-body"> + {{ msg() }} {% for message in errors|default([]) %} {{ m.alert(__(message), 'danger') }} {% endfor %} @@ -50,7 +51,7 @@ <div class="input-group"> <span class="input-group-addon input-lg">{{ m.glyphicon('lock') }}</span> <input class="form-control input-lg" id="form_password" - type="password" name="password" value="" placeholder="{{ __('Password') }}"> + type="password" name="password" value="" placeholder="{{ __('Password') }}"> </div> </div> @@ -61,7 +62,7 @@ </div> <div class="text-center"> - <a href="{{ url('user-password-recovery') }}" class=""> + <a href="{{ url('/password/reset') }}" class=""> {{ __('I forgot my password') }} </a> </div> @@ -93,8 +94,10 @@ </a> </div> </div> - </div> - {{ m.glyphicon('info-sign') }} {{ __('Please note: You have to activate cookies!') }} + <div class="col-md-12 text-center"> + {{ m.glyphicon('info-sign') }} {{ __('Please note: You have to activate cookies!') }} + </div> + </div> </div> {% endblock %} diff --git a/resources/views/pages/password/reset-form.twig b/resources/views/pages/password/reset-form.twig new file mode 100644 index 00000000..60eb2499 --- /dev/null +++ b/resources/views/pages/password/reset-form.twig @@ -0,0 +1,18 @@ +{% extends "pages/password/reset.twig" %} +{% import 'macros/base.twig' as m %} +{% import 'macros/form.twig' as f %} + +{% block row_content %} + <div class="col-md-8"> + <form action="" enctype="multipart/form-data" method="post"> + {{ csrf() }} + + {{ f.input('password', __('Password'), 'password', true) }} + {{ f.input('password_confirmation', __('Confirm password'), 'password', true) }} + + <div class="form-group"> + {{ f.submit(__('Save')) }} + </div> + </form> + </div> +{% endblock %} diff --git a/resources/views/pages/password/reset-success.twig b/resources/views/pages/password/reset-success.twig new file mode 100644 index 00000000..19b8a93e --- /dev/null +++ b/resources/views/pages/password/reset-success.twig @@ -0,0 +1,12 @@ +{% extends "pages/password/reset.twig" %} +{% import 'macros/base.twig' as m %} + +{% block row_content %} + <div class="col-md-12"> + {% if type == 'email' %} + {{ m.alert(__('We sent you an email containing your password recovery link.'), 'info') }} + {% elseif type == 'reset' %} + {{ m.alert(__('Password saved.'), 'success') }} + {% endif %} + </div> +{% endblock %} diff --git a/resources/views/pages/password/reset.twig b/resources/views/pages/password/reset.twig new file mode 100644 index 00000000..289152ea --- /dev/null +++ b/resources/views/pages/password/reset.twig @@ -0,0 +1,32 @@ +{% extends 'layouts/app.twig' %} +{% import 'macros/base.twig' as m %} +{% import 'macros/form.twig' as f %} + +{% block title %}{{ __('Password recovery') }}{% endblock %} + +{% block content %} + <div class="container"> + <h1>{{ __('Password recovery') }}</h1> + + {% for message in errors|default([]) %} + {{ m.alert(__(message), 'danger') }} + {% endfor %} + + <div class="row"> + {% block row_content %} + <div class="col-md-8"> + <form action="" enctype="multipart/form-data" method="post"> + {{ csrf() }} + + {{ __('We will send you an e-mail with a password recovery link. Please use the email address you used for registration.') }} + {{ f.input('email', __('E-Mail'), 'email', true) }} + + <div class="form-group"> + {{ f.submit(__('Recover')) }} + </div> + </form> + </div> + {% endblock %} + </div> + </div> +{% endblock %} diff --git a/resources/views/pages/user-shifts.html b/resources/views/pages/user-shifts.html index 9ac501da..d5a98f80 100644 --- a/resources/views/pages/user-shifts.html +++ b/resources/views/pages/user-shifts.html @@ -55,12 +55,12 @@ <div class="col-md-6"> <button class="btn btn-info btn-sm hidden-print" style="margin-top: 20px; margin-bottom:10px" type="button" data-toggle="collapse" - data-target="#collapseRoomSelect" aria-expanded="false" - aria-controls="collapseRoomSelect" + data-target="#collapseShiftsFilterSelect" aria-expanded="true" + aria-controls="collapseShiftsFilterSelect" > collapse/show filters </button> - <div class="collapse in" id="collapseRoomSelect"> + <div class="collapse in" id="collapseShiftsFilterSelect"> <div class="row"> <div class="col-xs-4 col-xxs-12">%room_select%</div> <div class="col-xs-4 col-xxs-12">%type_select%</div> @@ -79,5 +79,5 @@ %shifts_table% <div class="hidden-print"> -%ical_text% + %ical_text% </div>
\ No newline at end of file diff --git a/src/Controllers/AuthController.php b/src/Controllers/AuthController.php index c69c2377..7892064b 100644 --- a/src/Controllers/AuthController.php +++ b/src/Controllers/AuthController.php @@ -88,7 +88,7 @@ class AuthController extends BaseController $user = $this->auth->authenticate($data['login'], $data['password']); if (!$user instanceof User) { - $this->session->set('errors', $this->session->get('errors', []) + ['auth.not-found']); + $this->session->set('errors', array_merge($this->session->get('errors', []), ['auth.not-found'])); return $this->showLogin(); } diff --git a/src/Controllers/PasswordResetController.php b/src/Controllers/PasswordResetController.php new file mode 100644 index 00000000..505ed8eb --- /dev/null +++ b/src/Controllers/PasswordResetController.php @@ -0,0 +1,167 @@ +<?php + +namespace Engelsystem\Controllers; + +use Engelsystem\Http\Exceptions\HttpNotFound; +use Engelsystem\Http\Request; +use Engelsystem\Http\Response; +use Engelsystem\Mail\EngelsystemMailer; +use Engelsystem\Models\User\PasswordReset; +use Engelsystem\Models\User\User; +use Illuminate\Support\Arr; +use Illuminate\Support\Collection; +use Psr\Log\LoggerInterface; +use Symfony\Component\HttpFoundation\Session\SessionInterface; + +class PasswordResetController extends BaseController +{ + /** @var LoggerInterface */ + protected $log; + + /** @var EngelsystemMailer */ + protected $mail; + + /** @var Response */ + protected $response; + + /** @var SessionInterface */ + protected $session; + + /** @var array */ + protected $permissions = [ + 'reset' => 'login', + 'postReset' => 'login', + 'resetPassword' => 'login', + 'postResetPassword' => 'login', + ]; + + /** + * @param Response $response + * @param SessionInterface $session + * @param EngelsystemMailer $mail + * @param LoggerInterface $log + */ + public function __construct( + Response $response, + SessionInterface $session, + EngelsystemMailer $mail, + LoggerInterface $log + ) { + $this->log = $log; + $this->mail = $mail; + $this->response = $response; + $this->session = $session; + } + + /** + * @return Response + */ + public function reset(): Response + { + return $this->showView('pages/password/reset'); + } + + /** + * @param Request $request + * @return Response + */ + public function postReset(Request $request): Response + { + $data = $this->validate($request, [ + 'email' => 'required|email', + ]); + + /** @var User $user */ + $user = User::whereEmail($data['email'])->first(); + if ($user) { + $reset = PasswordReset::findOrNew($user->id); + $reset->user_id = $user->id; + $reset->token = md5(random_bytes(64)); + $reset->save(); + + $this->log->info( + sprintf('Password recovery for %s (%u)', $user->name, $user->id), + ['user' => $user->toJson()] + ); + + $this->mail->sendViewTranslated( + $user, + 'Password recovery', + 'emails/password-reset', + ['username' => $user->name, 'reset' => $reset] + ); + } + + return $this->showView('pages/password/reset-success', ['type' => 'email']); + } + + /** + * @param Request $request + * @return Response + */ + public function resetPassword(Request $request): Response + { + $this->requireToken($request); + + return $this->showView('pages/password/reset-form'); + } + + /** + * @param Request $request + * @return Response + */ + public function postResetPassword(Request $request): Response + { + $reset = $this->requireToken($request); + + $data = $this->validate($request, [ + 'password' => 'required|min:' . config('min_password_length'), + 'password_confirmation' => 'required', + ]); + + if ($data['password'] !== $data['password_confirmation']) { + $this->session->set('errors', + array_merge($this->session->get('errors', []), ['validation.password.confirmed'])); + + return $this->showView('pages/password/reset-form'); + } + + auth()->setPassword($reset->user, $data['password']); + $reset->delete(); + + return $this->showView('pages/password/reset-success', ['type' => 'reset']); + } + + /** + * @param string $view + * @param array $data + * @return Response + */ + protected function showView($view = 'pages/password/reset', $data = []): Response + { + $errors = Collection::make(Arr::flatten($this->session->get('errors', []))); + $this->session->remove('errors'); + + return $this->response->withView( + $view, + array_merge_recursive(['errors' => $errors], $data) + ); + } + + /** + * @param Request $request + * @return PasswordReset + */ + protected function requireToken(Request $request): PasswordReset + { + $token = $request->getAttribute('token'); + /** @var PasswordReset|null $reset */ + $reset = PasswordReset::whereToken($token)->first(); + + if (!$reset) { + throw new HttpNotFound(); + } + + return $reset; + } +} diff --git a/src/Database/DatabaseServiceProvider.php b/src/Database/DatabaseServiceProvider.php index d0068b54..b5225015 100644 --- a/src/Database/DatabaseServiceProvider.php +++ b/src/Database/DatabaseServiceProvider.php @@ -15,6 +15,7 @@ class DatabaseServiceProvider extends ServiceProvider { $config = $this->app->get('config'); $capsule = $this->app->make(CapsuleManager::class); + $now = Carbon::now($config->get('timezone')); $dbConfig = $config->get('database'); $capsule->addConnection(array_merge([ @@ -25,7 +26,7 @@ class DatabaseServiceProvider extends ServiceProvider 'password' => '', 'charset' => 'utf8', 'collation' => 'utf8_unicode_ci', - 'timezone' => Carbon::now()->format('P'), + 'timezone' => $now->format('P'), 'prefix' => '', ], $dbConfig)); diff --git a/src/Helpers/Translation/TranslationServiceProvider.php b/src/Helpers/Translation/TranslationServiceProvider.php index 09337dad..6df9b0fe 100644 --- a/src/Helpers/Translation/TranslationServiceProvider.php +++ b/src/Helpers/Translation/TranslationServiceProvider.php @@ -5,6 +5,7 @@ namespace Engelsystem\Helpers\Translation; use Engelsystem\Config\Config; use Engelsystem\Container\ServiceProvider; use Gettext\Translations; +use Illuminate\Support\Str; use Symfony\Component\HttpFoundation\Session\Session; class TranslationServiceProvider extends ServiceProvider @@ -40,8 +41,10 @@ class TranslationServiceProvider extends ServiceProvider 'localeChangeCallback' => [$this, 'setLocale'], ] ); - $this->app->instance(Translator::class, $translator); - $this->app->instance('translator', $translator); + $this->app->singleton(Translator::class, function () use ($translator) { + return $translator; + }); + $this->app->alias(Translator::class, 'translator'); } /** @@ -67,14 +70,18 @@ class TranslationServiceProvider extends ServiceProvider public function getTranslator(string $locale): GettextTranslator { if (!isset($this->translators[$locale])) { - $file = $this->app->get('path.lang') . '/' . $locale . '/default.mo'; + $file = $this->getFile($locale); /** @var GettextTranslator $translator */ $translator = $this->app->make(GettextTranslator::class); /** @var Translations $translations */ $translations = $this->app->make(Translations::class); - $translations->addFromMoFile($file); + if (Str::endsWith($file, '.mo')) { + $translations->addFromMoFile($file); + } else { + $translations->addFromPoFile($file); + } $translator->loadTranslations($translations); @@ -83,4 +90,20 @@ class TranslationServiceProvider extends ServiceProvider return $this->translators[$locale]; } + + /** + * @param string $locale + * @return string + */ + protected function getFile(string $locale): string + { + $filepath = $file = $this->app->get('path.lang') . '/' . $locale . '/default'; + $file = $filepath . '.mo'; + + if (!file_exists($file)) { + $file = $filepath . '.po'; + } + + return $file; + } } diff --git a/src/Http/Exceptions/HttpNotFound.php b/src/Http/Exceptions/HttpNotFound.php new file mode 100644 index 00000000..324adaf9 --- /dev/null +++ b/src/Http/Exceptions/HttpNotFound.php @@ -0,0 +1,23 @@ +<?php + +namespace Engelsystem\Http\Exceptions; + +use Throwable; + +class HttpNotFound extends HttpException +{ + /** + * @param string $message + * @param array $headers + * @param int $code + * @param Throwable|null $previous + */ + public function __construct( + string $message = '', + array $headers = [], + int $code = 0, + Throwable $previous = null + ) { + parent::__construct(404, $message, $headers, $code, $previous); + } +} diff --git a/src/Http/Response.php b/src/Http/Response.php index 1a7c8209..a6b4ab74 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -3,6 +3,7 @@ namespace Engelsystem\Http; use Engelsystem\Renderer\Renderer; +use InvalidArgumentException; use Psr\Http\Message\ResponseInterface; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; @@ -11,21 +12,21 @@ class Response extends SymfonyResponse implements ResponseInterface use MessageTrait; /** @var Renderer */ - protected $view; + protected $renderer; /** * @param string $content * @param int $status * @param array $headers - * @param Renderer $view + * @param Renderer $renderer */ public function __construct( $content = '', int $status = 200, array $headers = [], - Renderer $view = null + Renderer $renderer = null ) { - $this->view = $view; + $this->renderer = $renderer; parent::__construct($content, $status, $headers); } @@ -47,7 +48,7 @@ class Response extends SymfonyResponse implements ResponseInterface * provided status code; if none is provided, implementations MAY * use the defaults as suggested in the HTTP specification. * @return static - * @throws \InvalidArgumentException For invalid status code arguments. + * @throws InvalidArgumentException For invalid status code arguments. */ public function withStatus($code, $reasonPhrase = '') { @@ -107,12 +108,12 @@ class Response extends SymfonyResponse implements ResponseInterface */ public function withView($view, $data = [], $status = 200, $headers = []) { - if (!$this->view instanceof Renderer) { - throw new \InvalidArgumentException('Renderer not defined'); + if (!$this->renderer instanceof Renderer) { + throw new InvalidArgumentException('Renderer not defined'); } $new = clone $this; - $new->setContent($this->view->render($view, $data)); + $new->setContent($this->renderer->render($view, $data)); $new->setStatusCode($status, ($status == $this->getStatusCode() ? $this->statusText : null)); foreach ($headers as $key => $values) { @@ -144,4 +145,14 @@ class Response extends SymfonyResponse implements ResponseInterface return $response; } + + /** + * Set the renderer to use + * + * @param Renderer $renderer + */ + public function setRenderer(Renderer $renderer) + { + $this->renderer = $renderer; + } } diff --git a/src/Http/Validation/Rules/Between.php b/src/Http/Validation/Rules/Between.php new file mode 100644 index 00000000..106a93ac --- /dev/null +++ b/src/Http/Validation/Rules/Between.php @@ -0,0 +1,10 @@ +<?php + +namespace Engelsystem\Http\Validation\Rules; + +use Respect\Validation\Rules\Between as RespectBetween; + +class Between extends RespectBetween +{ + use StringInputLength; +} diff --git a/src/Http/Validation/Rules/Max.php b/src/Http/Validation/Rules/Max.php new file mode 100644 index 00000000..b1b2cfa3 --- /dev/null +++ b/src/Http/Validation/Rules/Max.php @@ -0,0 +1,10 @@ +<?php + +namespace Engelsystem\Http\Validation\Rules; + +use Respect\Validation\Rules\Max as RespectMax; + +class Max extends RespectMax +{ + use StringInputLength; +} diff --git a/src/Http/Validation/Rules/Min.php b/src/Http/Validation/Rules/Min.php new file mode 100644 index 00000000..ab8d4e1a --- /dev/null +++ b/src/Http/Validation/Rules/Min.php @@ -0,0 +1,10 @@ +<?php + +namespace Engelsystem\Http\Validation\Rules; + +use Respect\Validation\Rules\Min as RespectMin; + +class Min extends RespectMin +{ + use StringInputLength; +} diff --git a/src/Http/Validation/Rules/StringInputLength.php b/src/Http/Validation/Rules/StringInputLength.php new file mode 100644 index 00000000..7b5c248b --- /dev/null +++ b/src/Http/Validation/Rules/StringInputLength.php @@ -0,0 +1,44 @@ +<?php + +namespace Engelsystem\Http\Validation\Rules; + +use DateTime; +use Illuminate\Support\Str; +use Throwable; + +trait StringInputLength +{ + /** + * Use the input length of a string + * + * @param mixed $input + * @return bool + */ + public function validate($input): bool + { + if ( + is_string($input) + && !is_numeric($input) + && !$this->isDateTime($input) + ) { + $input = Str::length($input); + } + + return parent::validate($input); + } + + /** + * @param mixed $input + * @return bool + */ + protected function isDateTime($input): bool + { + try { + new DateTime($input); + } catch (Throwable $e) { + return false; + } + + return true; + } +} diff --git a/src/Mail/EngelsystemMailer.php b/src/Mail/EngelsystemMailer.php index 81660681..87915d67 100644 --- a/src/Mail/EngelsystemMailer.php +++ b/src/Mail/EngelsystemMailer.php @@ -2,6 +2,8 @@ namespace Engelsystem\Mail; +use Engelsystem\Helpers\Translation\Translator; +use Engelsystem\Models\User\User; use Engelsystem\Renderer\Renderer; use Swift_Mailer as SwiftMailer; @@ -10,30 +12,75 @@ class EngelsystemMailer extends Mailer /** @var Renderer|null */ protected $view; + /** @var Translator|null */ + protected $translation; + /** @var string */ protected $subjectPrefix = null; /** * @param SwiftMailer $mailer * @param Renderer $view + * @param Translator $translation */ - public function __construct(SwiftMailer $mailer, Renderer $view = null) + public function __construct(SwiftMailer $mailer, Renderer $view = null, Translator $translation = null) { parent::__construct($mailer); + $this->translation = $translation; $this->view = $view; } /** + * @param string|string[]|User $to + * @param string $subject + * @param string $template + * @param array $data + * @param string|null $locale + * @return int + */ + public function sendViewTranslated( + $to, + string $subject, + string $template, + array $data = [], + ?string $locale = null + ): int { + if ($to instanceof User) { + $locale = $locale ?: $to->settings->language; + $to = $to->contact->email ? $to->contact->email : $to->email; + } + + $activeLocale = null; + if ( + $locale + && $this->translation + && isset($this->translation->getLocales()[$locale]) + ) { + $activeLocale = $this->translation->getLocale(); + $this->translation->setLocale($locale); + } + + $subject = $this->translation ? $this->translation->translate($subject) : $subject; + $sentMails = $this->sendView($to, $subject, $template, $data); + + if ($activeLocale) { + $this->translation->setLocale($activeLocale); + } + + return $sentMails; + } + + /** * Send a template * - * @param string $to - * @param string $subject - * @param string $template - * @param array $data + * @param string|string[] $to + * @param string $subject + * @param string $template + * @param array $data * @return int */ - public function sendView($to, $subject, $template, $data = []): int + public function sendView($to, string $subject, string $template, array $data = []): int { $body = $this->view->render($template, $data); diff --git a/src/Middleware/LegacyMiddleware.php b/src/Middleware/LegacyMiddleware.php index 27a15faa..11508e1c 100644 --- a/src/Middleware/LegacyMiddleware.php +++ b/src/Middleware/LegacyMiddleware.php @@ -26,7 +26,6 @@ class LegacyMiddleware implements MiddlewareInterface 'shifts_json_export', 'users', 'user_driver_licenses', - 'user_password_recovery', 'user_worklog', ]; @@ -112,11 +111,6 @@ class LegacyMiddleware implements MiddlewareInterface case 'shifts_json_export': require_once realpath(__DIR__ . '/../../includes/controller/shifts_controller.php'); shifts_json_export_controller(); - case 'user_password_recovery': - require_once realpath(__DIR__ . '/../../includes/controller/users_controller.php'); - $title = user_password_recovery_title(); - $content = user_password_recovery_controller(); - return [$title, $content]; case 'public_dashboard': return public_dashboard_controller(); case 'angeltypes': diff --git a/src/Renderer/Twig/Extensions/Legacy.php b/src/Renderer/Twig/Extensions/Legacy.php index 79de32cb..55c095fc 100644 --- a/src/Renderer/Twig/Extensions/Legacy.php +++ b/src/Renderer/Twig/Extensions/Legacy.php @@ -32,6 +32,7 @@ class Legacy extends TwigExtension new TwigFunction('menuUserHints', 'header_render_hints', $isSafeHtml), new TwigFunction('menuUserSubmenu', 'make_user_submenu', $isSafeHtml), new TwigFunction('page', [$this, 'getPage']), + new TwigFunction('msg', 'msg', $isSafeHtml), ]; } diff --git a/storage/.gitignore b/storage/.gitignore new file mode 100644 index 00000000..fa776e4d --- /dev/null +++ b/storage/.gitignore @@ -0,0 +1,4 @@ +/* +!/app +!/cache +!.gitignore diff --git a/storage/cache/.gitignore b/storage/cache/.gitignore index ea604cb2..b376bb75 100644 --- a/storage/cache/.gitignore +++ b/storage/cache/.gitignore @@ -1 +1,3 @@ -/routes.cache.php +/* +!/views +!.gitignore diff --git a/tests/Feature/Database/DatabaseServiceProviderTest.php b/tests/Feature/Database/DatabaseServiceProviderTest.php index d66ed25c..aa4dbc7b 100644 --- a/tests/Feature/Database/DatabaseServiceProviderTest.php +++ b/tests/Feature/Database/DatabaseServiceProviderTest.php @@ -2,10 +2,9 @@ namespace Engelsystem\Test\Feature\Database; -use Engelsystem\Application; use Engelsystem\Config\Config; +use Engelsystem\Database\Database; use Engelsystem\Database\DatabaseServiceProvider; -use PHPUnit\Framework\MockObject\MockObject; class DatabaseServiceProviderTest extends DatabaseTest { @@ -14,27 +13,13 @@ class DatabaseServiceProviderTest extends DatabaseTest */ public function testRegister() { - /** @var Config|MockObject $config */ - $config = $this->getMockBuilder(Config::class) - ->getMock(); + $this->app->instance('config', new Config([ + 'database' => $this->getDbConfig(), + 'timezone' => 'UTC', + ])); - /** @var Application|MockObject $app */ - $app = $this->getMockBuilder(Application::class) - ->setMethods(['get']) - ->getMock(); - Application::setInstance($app); - - $app->expects($this->once()) - ->method('get') - ->with('config') - ->willReturn($config); - - $config->expects($this->atLeastOnce()) - ->method('get') - ->with('database') - ->willReturn($this->getDbConfig()); - - $serviceProvider = new DatabaseServiceProvider($app); + $serviceProvider = new DatabaseServiceProvider($this->app); $serviceProvider->register(); + $this->assertTrue($this->app->has(Database::class)); } } diff --git a/tests/Feature/Database/DatabaseTest.php b/tests/Feature/Database/DatabaseTest.php index 11df6779..0116e526 100644 --- a/tests/Feature/Database/DatabaseTest.php +++ b/tests/Feature/Database/DatabaseTest.php @@ -2,7 +2,7 @@ namespace Engelsystem\Test\Feature\Database; -use PHPUnit\Framework\TestCase; +use Engelsystem\Test\Unit\TestCase; abstract class DatabaseTest extends TestCase { diff --git a/tests/Unit/Controllers/AuthControllerTest.php b/tests/Unit/Controllers/AuthControllerTest.php index 6c237264..a12ed6d6 100644 --- a/tests/Unit/Controllers/AuthControllerTest.php +++ b/tests/Unit/Controllers/AuthControllerTest.php @@ -12,9 +12,8 @@ use Engelsystem\Http\Validation\Validator; use Engelsystem\Models\User\Settings; use Engelsystem\Models\User\User; use Engelsystem\Test\Unit\HasDatabase; -use Illuminate\Support\Collection; +use Engelsystem\Test\Unit\TestCase; use PHPUnit\Framework\MockObject\MockObject; -use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; @@ -66,6 +65,7 @@ class AuthControllerTest extends TestCase $session = new Session(new MockArraySessionStorage()); /** @var Validator|MockObject $validator */ $validator = new Validator(); + $session->set('errors', [['bar' => 'some.bar.error']]); $user = new User([ 'name' => 'foo', @@ -89,7 +89,7 @@ class AuthControllerTest extends TestCase $response->expects($this->once()) ->method('withView') - ->with('pages/login', ['errors' => Collection::make(['auth.not-found'])]) + ->with('pages/login', ['errors' => collect(['some.bar.error', 'auth.not-found'])]) ->willReturn($response); $response->expects($this->once()) ->method('redirectTo') diff --git a/tests/Unit/Controllers/Metrics/StatsTest.php b/tests/Unit/Controllers/Metrics/StatsTest.php index fa78d8c3..9204f7db 100644 --- a/tests/Unit/Controllers/Metrics/StatsTest.php +++ b/tests/Unit/Controllers/Metrics/StatsTest.php @@ -155,8 +155,8 @@ class StatsTest extends TestCase $this->initDatabase(); $this->addUsers(); - (new PasswordReset(['use_id' => 1, 'token' => 'loremIpsum123']))->save(); - (new PasswordReset(['use_id' => 3, 'token' => '5omeR4nd0mTok3N']))->save(); + (new PasswordReset(['user_id' => 1, 'token' => 'loremIpsum123']))->save(); + (new PasswordReset(['user_id' => 3, 'token' => '5omeR4nd0mTok3N']))->save(); $stats = new Stats($this->database); $this->assertEquals(2, $stats->passwordResets()); diff --git a/tests/Unit/Controllers/PasswordResetControllerTest.php b/tests/Unit/Controllers/PasswordResetControllerTest.php new file mode 100644 index 00000000..54046cef --- /dev/null +++ b/tests/Unit/Controllers/PasswordResetControllerTest.php @@ -0,0 +1,266 @@ +<?php + +namespace Engelsystem\Test\Unit\Controllers; + +use Engelsystem\Config\Config; +use Engelsystem\Controllers\PasswordResetController; +use Engelsystem\Helpers\Authenticator; +use Engelsystem\Http\Exceptions\HttpNotFound; +use Engelsystem\Http\Exceptions\ValidationException; +use Engelsystem\Http\Request; +use Engelsystem\Http\Response; +use Engelsystem\Http\Validation\Validator; +use Engelsystem\Mail\EngelsystemMailer; +use Engelsystem\Models\User\PasswordReset; +use Engelsystem\Models\User\User; +use Engelsystem\Renderer\Renderer; +use Engelsystem\Test\Unit\HasDatabase; +use Engelsystem\Test\Unit\TestCase; +use PHPUnit\Framework\MockObject\MockObject; +use Psr\Log\Test\TestLogger; +use Symfony\Component\HttpFoundation\Session\Session; +use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; + +class PasswordResetControllerTest extends TestCase +{ + use HasDatabase; + + /** @var array */ + protected $args = []; + + /** + * @covers \Engelsystem\Controllers\PasswordResetController::reset + * @covers \Engelsystem\Controllers\PasswordResetController::__construct + */ + public function testReset(): void + { + $controller = $this->getController('pages/password/reset'); + $response = $controller->reset(); + + $this->assertEquals(200, $response->getStatusCode()); + } + + /** + * @covers \Engelsystem\Controllers\PasswordResetController::postReset + */ + public function testPostReset(): void + { + $this->initDatabase(); + $request = new Request([], ['email' => 'foo@bar.batz']); + $user = $this->createUser(); + + $controller = $this->getController( + 'pages/password/reset-success', + ['type' => 'email', 'errors' => collect()] + ); + /** @var TestLogger $log */ + $log = $this->args['log']; + /** @var EngelsystemMailer|MockObject $mailer */ + $mailer = $this->args['mailer']; + $this->setExpects($mailer, 'sendViewTranslated'); + + $controller->postReset($request); + + $this->assertNotEmpty(PasswordReset::find($user->id)->first()); + $this->assertTrue($log->hasInfoThatContains($user->name)); + } + + /** + * @covers \Engelsystem\Controllers\PasswordResetController::postReset + */ + public function testPostResetInvalidRequest(): void + { + $request = new Request(); + + $controller = $this->getController(); + + $this->expectException(ValidationException::class); + $controller->postReset($request); + } + + /** + * @covers \Engelsystem\Controllers\PasswordResetController::postReset + */ + public function testPostResetNoUser(): void + { + $this->initDatabase(); + $request = new Request([], ['email' => 'foo@bar.batz']); + + $controller = $this->getController( + 'pages/password/reset-success', + ['type' => 'email', 'errors' => collect()] + ); + + $controller->postReset($request); + } + + /** + * @covers \Engelsystem\Controllers\PasswordResetController::resetPassword + * @covers \Engelsystem\Controllers\PasswordResetController::requireToken + */ + public function testResetPassword(): void + { + $this->initDatabase(); + + $user = $this->createUser(); + $token = $this->createToken($user); + $request = new Request([], [], ['token' => $token->token]); + + $controller = $this->getController('pages/password/reset-form'); + + $controller->resetPassword($request); + } + + /** + * @covers \Engelsystem\Controllers\PasswordResetController::resetPassword + * @covers \Engelsystem\Controllers\PasswordResetController::requireToken + */ + public function testResetPasswordNoToken(): void + { + $this->initDatabase(); + $controller = $this->getController(); + + $this->expectException(HttpNotFound::class); + $controller->resetPassword(new Request()); + } + + /** + * @covers \Engelsystem\Controllers\PasswordResetController::postResetPassword + */ + public function testPostResetPassword(): void + { + $this->initDatabase(); + + $this->app->instance('config', new Config(['min_password_length' => 3])); + $user = $this->createUser(); + $token = $this->createToken($user); + $password = 'SomeRandomPasswordForAmazingSecurity'; + $request = new Request( + [], + ['password' => $password, 'password_confirmation' => $password], + ['token' => $token->token] + ); + + $controller = $this->getController( + 'pages/password/reset-success', + ['type' => 'reset', 'errors' => collect()] + ); + + $auth = new Authenticator($request, $this->args['session'], $user); + $this->app->instance('authenticator', $auth); + + $response = $controller->postResetPassword($request); + $this->assertEquals(200, $response->getStatusCode()); + + $this->assertEmpty(PasswordReset::find($user->id)); + $this->assertNotNull(auth()->authenticate($user->name, $password)); + } + + /** + * @covers \Engelsystem\Controllers\PasswordResetController::postResetPassword + * @covers \Engelsystem\Controllers\PasswordResetController::showView + */ + public function testPostResetPasswordNotMatching(): void + { + $this->initDatabase(); + + $this->app->instance('config', new Config(['min_password_length' => 3])); + $user = $this->createUser(); + $token = $this->createToken($user); + $password = 'SomeRandomPasswordForAmazingSecurity'; + $request = new Request( + [], + ['password' => $password, 'password_confirmation' => $password . 'OrNot'], + ['token' => $token->token] + ); + + $controller = $this->getController( + 'pages/password/reset-form', + ['errors' => collect(['some.other.error', 'validation.password.confirmed'])] + ); + /** @var Session $session */ + $session = $this->args['session']; + $session->set('errors', ['foo' => ['bar' => 'some.other.error']]); + + $controller->postResetPassword($request); + $this->assertEmpty($session->get('errors')); + } + + /** + * @return array + */ + protected function getControllerArgs(): array + { + $response = new Response(); + $session = new Session(new MockArraySessionStorage()); + /** @var EngelsystemMailer|MockObject $mailer */ + $mailer = $this->createMock(EngelsystemMailer::class); + $log = new TestLogger(); + $renderer = $this->createMock(Renderer::class); + $response->setRenderer($renderer); + + return $this->args = [ + 'response' => $response, + 'session' => $session, + 'mailer' => $mailer, + 'log' => $log, + 'renderer' => $renderer + ]; + } + + /** + * @param string $view + * @param array $data + * @return PasswordResetController + */ + protected function getController(?string $view = null, ?array $data = null): PasswordResetController + { + /** @var Response $response */ + /** @var Session $session */ + /** @var EngelsystemMailer|MockObject $mailer */ + /** @var TestLogger $log */ + /** @var Renderer|MockObject $renderer */ + list($response, $session, $mailer, $log, $renderer) = array_values($this->getControllerArgs()); + $controller = new PasswordResetController($response, $session, $mailer, $log); + $controller->setValidator(new Validator()); + + if ($view) { + $args = [$view]; + if ($data) { + $args[] = $data; + } + + $this->setExpects($renderer, 'render', $args, 'Foo'); + } + + return $controller; + } + + /** + * @return User + */ + protected function createUser(): User + { + $user = new User([ + 'name' => 'foo', + 'password' => '', + 'email' => 'foo@bar.batz', + 'api_key' => '', + ]); + $user->save(); + + return $user; + } + + /** + * @param User $user + * @return PasswordReset + */ + protected function createToken(User $user): PasswordReset + { + $reset = new PasswordReset(['user_id' => $user->id, 'token' => 'SomeTestToken123']); + $reset->save(); + + return $reset; + } +} diff --git a/tests/Unit/Database/DatabaseServiceProviderTest.php b/tests/Unit/Database/DatabaseServiceProviderTest.php index c3e4c5d0..241c47b7 100644 --- a/tests/Unit/Database/DatabaseServiceProviderTest.php +++ b/tests/Unit/Database/DatabaseServiceProviderTest.php @@ -100,7 +100,10 @@ class DatabaseServiceProviderTest extends ServiceProviderTest $app = $this->getApp(['get', 'make', 'instance']); $this->setExpects($app, 'get', ['config'], $config); - $this->setExpects($config, 'get', ['database'], $dbConfigData, $this->atLeastOnce()); + $config->expects($this->exactly(2)) + ->method('get') + ->withConsecutive(['timezone'], ['database']) + ->willReturnOnConsecutiveCalls('UTC', $dbConfigData); $app->expects($this->atLeastOnce()) ->method('make') diff --git a/tests/Unit/HasDatabase.php b/tests/Unit/HasDatabase.php index 7a58bb2b..dbaa253e 100644 --- a/tests/Unit/HasDatabase.php +++ b/tests/Unit/HasDatabase.php @@ -2,7 +2,6 @@ namespace Engelsystem\Test\Unit; -use Engelsystem\Application; use Engelsystem\Database\Database; use Engelsystem\Database\Migration\Migrate; use Engelsystem\Database\Migration\MigrationServiceProvider; @@ -27,12 +26,11 @@ trait HasDatabase $connection->getPdo()->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $this->database = new Database($connection); - $app = new Application(); - $app->instance(Database::class, $this->database); - $app->register(MigrationServiceProvider::class); + $this->app->instance(Database::class, $this->database); + $this->app->register(MigrationServiceProvider::class); /** @var Migrate $migration */ - $migration = $app->get('db.migration'); + $migration = $this->app->get('db.migration'); $migration->initMigration(); $this->database diff --git a/tests/Unit/Helpers/Translation/Assets/ba_RR/default.po b/tests/Unit/Helpers/Translation/Assets/ba_RR/default.po new file mode 100644 index 00000000..887e2daa --- /dev/null +++ b/tests/Unit/Helpers/Translation/Assets/ba_RR/default.po @@ -0,0 +1,3 @@ +# Testing content +msgid "foo.bar" +msgstr "B Arr!" diff --git a/tests/Unit/Helpers/Translation/TranslationServiceProviderTest.php b/tests/Unit/Helpers/Translation/TranslationServiceProviderTest.php index 91307bdd..e55fdf02 100644 --- a/tests/Unit/Helpers/Translation/TranslationServiceProviderTest.php +++ b/tests/Unit/Helpers/Translation/TranslationServiceProviderTest.php @@ -12,7 +12,7 @@ use Symfony\Component\HttpFoundation\Session\Session; class TranslationServiceProviderTest extends ServiceProviderTest { /** - * @covers \Engelsystem\Helpers\Translation\TranslationServiceProvider::register() + * @covers \Engelsystem\Helpers\Translation\TranslationServiceProvider::register */ public function testRegister(): void { @@ -21,7 +21,7 @@ class TranslationServiceProviderTest extends ServiceProviderTest $locales = ['fo_OO' => 'Foo', 'fo_OO.BAR' => 'Foo (Bar)', 'te_ST.WTF-9' => 'WTF\'s Testing?']; $config = new Config(['locales' => $locales, 'default_locale' => $defaultLocale]); - $app = $this->getApp(['make', 'instance', 'get']); + $app = $this->getApp(['make', 'singleton', 'alias', 'get']); /** @var Session|MockObject $session */ $session = $this->createMock(Session::class); /** @var Translator|MockObject $translator */ @@ -30,7 +30,7 @@ class TranslationServiceProviderTest extends ServiceProviderTest /** @var TranslationServiceProvider|MockObject $serviceProvider */ $serviceProvider = $this->getMockBuilder(TranslationServiceProvider::class) ->setConstructorArgs([$app]) - ->setMethods(['setLocale']) + ->onlyMethods(['setLocale']) ->getMock(); $app->expects($this->exactly(2)) @@ -60,18 +60,22 @@ class TranslationServiceProviderTest extends ServiceProviderTest ) ->willReturn($translator); - $app->expects($this->exactly(2)) - ->method('instance') - ->withConsecutive( - [Translator::class, $translator], - ['translator', $translator] - ); + $app->expects($this->once()) + ->method('singleton') + ->willReturnCallback(function (string $abstract, callable $callback) use ($translator) { + $this->assertEquals(Translator::class, $abstract); + $this->assertEquals($translator, $callback()); + }); + + $app->expects($this->once()) + ->method('alias') + ->with(Translator::class, 'translator'); $serviceProvider->register(); } /** - * @covers \Engelsystem\Helpers\Translation\TranslationServiceProvider::getTranslator() + * @covers \Engelsystem\Helpers\Translation\TranslationServiceProvider::getTranslator */ public function testGetTranslator(): void { @@ -87,4 +91,20 @@ class TranslationServiceProviderTest extends ServiceProviderTest // Retry from cache $serviceProvider->getTranslator('fo_OO'); } + + /** + * @covers \Engelsystem\Helpers\Translation\TranslationServiceProvider::getTranslator + * @covers \Engelsystem\Helpers\Translation\TranslationServiceProvider::getFile + */ + public function testGetTranslatorFromPo(): void + { + $app = $this->getApp(['get']); + $this->setExpects($app, 'get', ['path.lang'], __DIR__ . '/Assets'); + + $serviceProvider = new TranslationServiceProvider($app); + + // Get translator using a .po file + $translator = $serviceProvider->getTranslator('ba_RR'); + $this->assertEquals('B Arr!', $translator->gettext('foo.bar')); + } } diff --git a/tests/Unit/Http/Exceptions/HttpNotFoundTest.php b/tests/Unit/Http/Exceptions/HttpNotFoundTest.php new file mode 100644 index 00000000..a39ea087 --- /dev/null +++ b/tests/Unit/Http/Exceptions/HttpNotFoundTest.php @@ -0,0 +1,22 @@ +<?php + +namespace Engelsystem\Test\Unit\Http\Exceptions; + +use Engelsystem\Http\Exceptions\HttpNotFound; +use PHPUnit\Framework\TestCase; + +class HttpNotFoundTest extends TestCase +{ + /** + * @covers \Engelsystem\Http\Exceptions\HttpNotFound::__construct + */ + public function testConstruct() + { + $exception = new HttpNotFound(); + $this->assertEquals(404, $exception->getStatusCode()); + $this->assertEquals('', $exception->getMessage()); + + $exception = new HttpNotFound('Nothing to see here!'); + $this->assertEquals('Nothing to see here!', $exception->getMessage()); + } +} diff --git a/tests/Unit/Http/ResponseTest.php b/tests/Unit/Http/ResponseTest.php index 34f76513..b8e6e527 100644 --- a/tests/Unit/Http/ResponseTest.php +++ b/tests/Unit/Http/ResponseTest.php @@ -55,6 +55,7 @@ class ResponseTest extends TestCase /** * @covers \Engelsystem\Http\Response::withView + * @covers \Engelsystem\Http\Response::setRenderer */ public function testWithView() { @@ -73,6 +74,17 @@ class ResponseTest extends TestCase $this->assertEquals('Foo ipsum!', $newResponse->getContent()); $this->assertEquals(505, $newResponse->getStatusCode()); $this->assertArraySubset(['test' => ['er']], $newResponse->getHeaders()); + + /** @var REnderer|MockObject $renderer */ + $anotherRenderer = $this->createMock(Renderer::class); + $anotherRenderer->expects($this->once()) + ->method('render') + ->with('bar') + ->willReturn('Stuff'); + + $response->setRenderer($anotherRenderer); + $response = $response->withView('bar'); + $this->assertEquals('Stuff', $response->getContent()); } /** diff --git a/tests/Unit/Http/SessionHandlers/DatabaseHandlerTest.php b/tests/Unit/Http/SessionHandlers/DatabaseHandlerTest.php index 14f23c00..0325ccfe 100644 --- a/tests/Unit/Http/SessionHandlers/DatabaseHandlerTest.php +++ b/tests/Unit/Http/SessionHandlers/DatabaseHandlerTest.php @@ -4,7 +4,7 @@ namespace Engelsystem\Test\Unit\Http\SessionHandlers; use Engelsystem\Http\SessionHandlers\DatabaseHandler; use Engelsystem\Test\Unit\HasDatabase; -use PHPUnit\Framework\TestCase; +use Engelsystem\Test\Unit\TestCase; class DatabaseHandlerTest extends TestCase { @@ -90,6 +90,7 @@ class DatabaseHandlerTest extends TestCase */ protected function setUp(): void { + parent::setUp(); $this->initDatabase(); } } diff --git a/tests/Unit/Http/Validation/Rules/BetweenTest.php b/tests/Unit/Http/Validation/Rules/BetweenTest.php new file mode 100644 index 00000000..130d2f93 --- /dev/null +++ b/tests/Unit/Http/Validation/Rules/BetweenTest.php @@ -0,0 +1,28 @@ +<?php + +namespace Engelsystem\Test\Unit\Http\Validation\Rules; + +use Engelsystem\Http\Validation\Rules\Between; +use Engelsystem\Test\Unit\TestCase; + +class BetweenTest extends TestCase +{ + /** + * @covers \Engelsystem\Http\Validation\Rules\Between + */ + public function testValidate() + { + $rule = new Between(3, 10); + $this->assertFalse($rule->validate(1)); + $this->assertFalse($rule->validate('11')); + $this->assertTrue($rule->validate(5)); + $this->assertFalse($rule->validate('AS')); + $this->assertFalse($rule->validate('TestContentThatCounts')); + $this->assertTrue($rule->validate('TESTING')); + + $rule = new Between('2042-01-01', '2042-10-10'); + $this->assertFalse($rule->validate('2000-01-01')); + $this->assertFalse($rule->validate('3000-01-01')); + $this->assertTrue($rule->validate('2042-05-11')); + } +} diff --git a/tests/Unit/Http/Validation/Rules/MaxTest.php b/tests/Unit/Http/Validation/Rules/MaxTest.php new file mode 100644 index 00000000..3f4d9516 --- /dev/null +++ b/tests/Unit/Http/Validation/Rules/MaxTest.php @@ -0,0 +1,26 @@ +<?php + +namespace Engelsystem\Test\Unit\Http\Validation\Rules; + +use Engelsystem\Http\Validation\Rules\Max; +use Engelsystem\Test\Unit\TestCase; + +class MaxTest extends TestCase +{ + /** + * @covers \Engelsystem\Http\Validation\Rules\Max + */ + public function testValidate() + { + $rule = new Max(3); + $this->assertFalse($rule->validate(10)); + $this->assertFalse($rule->validate('22')); + $this->assertTrue($rule->validate(3)); + $this->assertFalse($rule->validate('TEST')); + $this->assertTrue($rule->validate('AS')); + + $rule = new Max('2042-01-01'); + $this->assertFalse($rule->validate('2100-01-01')); + $this->assertTrue($rule->validate('2000-01-01')); + } +} diff --git a/tests/Unit/Http/Validation/Rules/MinTest.php b/tests/Unit/Http/Validation/Rules/MinTest.php new file mode 100644 index 00000000..56350802 --- /dev/null +++ b/tests/Unit/Http/Validation/Rules/MinTest.php @@ -0,0 +1,26 @@ +<?php + +namespace Engelsystem\Test\Unit\Http\Validation\Rules; + +use Engelsystem\Http\Validation\Rules\Min; +use Engelsystem\Test\Unit\TestCase; + +class MinTest extends TestCase +{ + /** + * @covers \Engelsystem\Http\Validation\Rules\Min + */ + public function testValidate() + { + $rule = new Min(3); + $this->assertFalse($rule->validate(1)); + $this->assertFalse($rule->validate('2')); + $this->assertTrue($rule->validate(3)); + $this->assertFalse($rule->validate('AS')); + $this->assertTrue($rule->validate('TEST')); + + $rule = new Min('2042-01-01'); + $this->assertFalse($rule->validate('2000-01-01')); + $this->assertTrue($rule->validate('2345-01-01')); + } +} diff --git a/tests/Unit/Http/Validation/Rules/StringInputLengthTest.php b/tests/Unit/Http/Validation/Rules/StringInputLengthTest.php new file mode 100644 index 00000000..5c4dc512 --- /dev/null +++ b/tests/Unit/Http/Validation/Rules/StringInputLengthTest.php @@ -0,0 +1,37 @@ +<?php + +namespace Engelsystem\Test\Unit\Http\Validation\Rules; + +use Engelsystem\Test\Unit\Http\Validation\Rules\Stub\UsesStringInputLength; +use Engelsystem\Test\Unit\TestCase; + +class StringInputLengthTest extends TestCase +{ + /** + * @covers \Engelsystem\Http\Validation\Rules\StringInputLength::validate + * @covers \Engelsystem\Http\Validation\Rules\StringInputLength::isDateTime + * @dataProvider validateProvider + * @param mixed $input + * @param mixed $expectedInput + */ + public function testValidate($input, $expectedInput) + { + $rule = new UsesStringInputLength(); + $rule->validate($input); + + $this->assertEquals($expectedInput, $rule->lastInput); + } + + /** + * @return array[] + */ + public function validateProvider() + { + return [ + ['TEST', 4], + ['?', 1], + ['2042-01-01 00:00', '2042-01-01 00:00'], + ['3', '3'], + ]; + } +} diff --git a/tests/Unit/Http/Validation/Rules/Stub/ParentClassImplementation.php b/tests/Unit/Http/Validation/Rules/Stub/ParentClassImplementation.php new file mode 100644 index 00000000..1b6aaaf5 --- /dev/null +++ b/tests/Unit/Http/Validation/Rules/Stub/ParentClassImplementation.php @@ -0,0 +1,23 @@ +<?php + +namespace Engelsystem\Test\Unit\Http\Validation\Rules\Stub; + +class ParentClassImplementation +{ + /** @var bool */ + public $validateResult = true; + + /** @var mixed */ + public $lastInput; + + /** + * @param mixed $input + * @return bool + */ + public function validate($input): bool + { + $this->lastInput = $input; + + return $this->validateResult; + } +} diff --git a/tests/Unit/Http/Validation/Rules/Stub/UsesStringInputLength.php b/tests/Unit/Http/Validation/Rules/Stub/UsesStringInputLength.php new file mode 100644 index 00000000..3522304c --- /dev/null +++ b/tests/Unit/Http/Validation/Rules/Stub/UsesStringInputLength.php @@ -0,0 +1,10 @@ +<?php + +namespace Engelsystem\Test\Unit\Http\Validation\Rules\Stub; + +use Engelsystem\Http\Validation\Rules\StringInputLength; + +class UsesStringInputLength extends ParentClassImplementation +{ + use StringInputLength; +} diff --git a/tests/Unit/Http/Validation/ValidatorTest.php b/tests/Unit/Http/Validation/ValidatorTest.php index 450e5d4e..124673df 100644 --- a/tests/Unit/Http/Validation/ValidatorTest.php +++ b/tests/Unit/Http/Validation/ValidatorTest.php @@ -50,9 +50,10 @@ class ValidatorTest extends TestCase )); $this->assertFalse($val->validate( - ['lorem' => 2], - ['lorem' => 'required|min:3|max:10'] + ['lorem' => 'OMG'], + ['lorem' => 'required|min:4|max:10'] )); + $this->assertEquals(['lorem' => ['validation.lorem.min']], $val->getErrors()); $this->assertFalse($val->validate( ['lorem' => 42], ['lorem' => 'required|min:3|max:10'] diff --git a/tests/Unit/Mail/EngelsystemMailerTest.php b/tests/Unit/Mail/EngelsystemMailerTest.php index 12dc3b0b..cdbdf435 100644 --- a/tests/Unit/Mail/EngelsystemMailerTest.php +++ b/tests/Unit/Mail/EngelsystemMailerTest.php @@ -2,15 +2,22 @@ namespace Engelsystem\Test\Unit\Mail; +use Engelsystem\Helpers\Translation\Translator; use Engelsystem\Mail\EngelsystemMailer; +use Engelsystem\Models\User\Contact; +use Engelsystem\Models\User\Settings; +use Engelsystem\Models\User\User; use Engelsystem\Renderer\Renderer; +use Engelsystem\Test\Unit\HasDatabase; +use Engelsystem\Test\Unit\TestCase; use PHPUnit\Framework\MockObject\MockObject; -use PHPUnit\Framework\TestCase; use Swift_Mailer as SwiftMailer; use Swift_Message as SwiftMessage; class EngelsystemMailerTest extends TestCase { + use HasDatabase; + /** * @covers \Engelsystem\Mail\EngelsystemMailer::__construct * @covers \Engelsystem\Mail\EngelsystemMailer::sendView @@ -24,22 +31,70 @@ class EngelsystemMailerTest extends TestCase /** @var EngelsystemMailer|MockObject $mailer */ $mailer = $this->getMockBuilder(EngelsystemMailer::class) ->setConstructorArgs(['mailer' => $swiftMailer, 'view' => $view]) - ->setMethods(['send']) + ->onlyMethods(['send']) ->getMock(); - $mailer->expects($this->once()) - ->method('send') - ->with('foo@bar.baz', 'Lorem dolor', 'Rendered Stuff!') - ->willReturn(1); - $view->expects($this->once()) - ->method('render') - ->with('test/template.tpl', ['dev' => true]) - ->willReturn('Rendered Stuff!'); + $this->setExpects($mailer, 'send', ['foo@bar.baz', 'Lorem dolor', 'Rendered Stuff!'], 1); + $this->setExpects($view, 'render', ['test/template.tpl', ['dev' => true]], 'Rendered Stuff!'); $return = $mailer->sendView('foo@bar.baz', 'Lorem dolor', 'test/template.tpl', ['dev' => true]); $this->equalTo(1, $return); } /** + * @covers \Engelsystem\Mail\EngelsystemMailer::sendViewTranslated + */ + public function testSendViewTranslated() + { + $this->initDatabase(); + + $settings = new Settings([ + 'language' => 'de_DE', + 'theme' => '', + ]); + $contact = new Contact(['email' => null]); + $user = new User([ + 'id' => 42, + 'name' => 'username', + 'email' => 'foo@bar.baz', + 'password' => '', + 'api_key' => '', + ]); + $user->save(); + $settings->user()->associate($user)->save(); + $contact->user()->associate($user)->save(); + + /** @var Renderer|MockObject $view */ + $view = $this->createMock(Renderer::class); + /** @var SwiftMailer|MockObject $swiftMailer */ + $swiftMailer = $this->createMock(SwiftMailer::class); + /** @var Translator|MockObject $translator */ + $translator = $this->createMock(Translator::class); + + /** @var EngelsystemMailer|MockObject $mailer */ + $mailer = $this->getMockBuilder(EngelsystemMailer::class) + ->setConstructorArgs(['mailer' => $swiftMailer, 'view' => $view, 'translation' => $translator]) + ->onlyMethods(['sendView']) + ->getMock(); + + $this->setExpects($mailer, 'sendView', ['foo@bar.baz', 'Lorem dolor', 'test/template.tpl', ['dev' => true]], 1); + $this->setExpects($translator, 'getLocales', null, ['de_DE' => 'de_DE', 'en_US' => 'en_US']); + $this->setExpects($translator, 'getLocale', null, 'en_US'); + $this->setExpects($translator, 'translate', ['translatable.text'], 'Lorem dolor'); + $translator->expects($this->exactly(2)) + ->method('setLocale') + ->withConsecutive(['de_DE'], ['en_US']); + + $return = $mailer->sendViewTranslated( + $user, + 'translatable.text', + 'test/template.tpl', + ['dev' => true], + 'de_DE' + ); + $this->equalTo(1, $return); + } + + /** * @covers \Engelsystem\Mail\EngelsystemMailer::getSubjectPrefix * @covers \Engelsystem\Mail\EngelsystemMailer::send * @covers \Engelsystem\Mail\EngelsystemMailer::setSubjectPrefix @@ -50,32 +105,12 @@ class EngelsystemMailerTest extends TestCase $message = $this->createMock(SwiftMessage::class); /** @var SwiftMailer|MockObject $swiftMailer */ $swiftMailer = $this->createMock(SwiftMailer::class); - $swiftMailer->expects($this->once()) - ->method('createMessage') - ->willReturn($message); - $swiftMailer->expects($this->once()) - ->method('send') - ->willReturn(1); - - $message->expects($this->once()) - ->method('setTo') - ->with(['to@xam.pel']) - ->willReturn($message); - - $message->expects($this->once()) - ->method('setFrom') - ->with('foo@bar.baz', 'Lorem Ipsum') - ->willReturn($message); - - $message->expects($this->once()) - ->method('setSubject') - ->with('[Mail test] Foo Bar') - ->willReturn($message); - - $message->expects($this->once()) - ->method('setBody') - ->with('Lorem Ipsum!') - ->willReturn($message); + $this->setExpects($swiftMailer, 'createMessage', null, $message); + $this->setExpects($swiftMailer, 'send', null, 1); + $this->setExpects($message, 'setTo', [['to@xam.pel']], $message); + $this->setExpects($message, 'setFrom', ['foo@bar.baz', 'Lorem Ipsum'], $message); + $this->setExpects($message, 'setSubject', ['[Mail test] Foo Bar'], $message); + $this->setExpects($message, 'setBody', ['Lorem Ipsum!'], $message); $mailer = new EngelsystemMailer($swiftMailer); $mailer->setFromAddress('foo@bar.baz'); diff --git a/tests/Unit/Models/EventConfigTest.php b/tests/Unit/Models/EventConfigTest.php index e2ab5d10..18d27007 100644 --- a/tests/Unit/Models/EventConfigTest.php +++ b/tests/Unit/Models/EventConfigTest.php @@ -5,7 +5,7 @@ namespace Engelsystem\Test\Unit\Models; use Carbon\Carbon; use Engelsystem\Models\EventConfig; use Engelsystem\Test\Unit\HasDatabase; -use PHPUnit\Framework\TestCase; +use Engelsystem\Test\Unit\TestCase; class EventConfigTest extends TestCase { @@ -102,7 +102,8 @@ class EventConfigTest extends TestCase */ protected function getEventConfig() { - return new class extends EventConfig { + return new class extends EventConfig + { /** * @param string $value * @param string $type @@ -122,6 +123,7 @@ class EventConfigTest extends TestCase */ protected function setUp(): void { + parent::setUp(); $this->initDatabase(); } } diff --git a/tests/Unit/Models/LogEntryTest.php b/tests/Unit/Models/LogEntryTest.php index 0a0efa3c..4b772cd0 100644 --- a/tests/Unit/Models/LogEntryTest.php +++ b/tests/Unit/Models/LogEntryTest.php @@ -4,7 +4,7 @@ namespace Engelsystem\Test\Unit\Models; use Engelsystem\Models\LogEntry; use Engelsystem\Test\Unit\HasDatabase; -use PHPUnit\Framework\TestCase; +use Engelsystem\Test\Unit\TestCase; use Psr\Log\LogLevel; class LogEntryTest extends TestCase @@ -38,6 +38,7 @@ class LogEntryTest extends TestCase */ protected function setUp(): void { + parent::setUp(); $this->initDatabase(); } } diff --git a/tests/Unit/Models/User/HasUserModelTest.php b/tests/Unit/Models/User/HasUserModelTest.php index 58c01e1e..4f6da9ad 100644 --- a/tests/Unit/Models/User/HasUserModelTest.php +++ b/tests/Unit/Models/User/HasUserModelTest.php @@ -5,8 +5,8 @@ namespace Engelsystem\Test\Unit\Models; use Engelsystem\Models\User\HasUserModel; use Engelsystem\Test\Unit\HasDatabase; use Engelsystem\Test\Unit\Models\User\Stub\HasUserModelImplementation; +use Engelsystem\Test\Unit\TestCase; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use PHPUnit\Framework\TestCase; class HasUserModelTest extends TestCase { @@ -28,6 +28,7 @@ class HasUserModelTest extends TestCase */ protected function setUp(): void { + parent::setUp(); $this->initDatabase(); } } diff --git a/tests/Unit/Models/User/UserTest.php b/tests/Unit/Models/User/UserTest.php index 0e17d137..3e793832 100644 --- a/tests/Unit/Models/User/UserTest.php +++ b/tests/Unit/Models/User/UserTest.php @@ -10,7 +10,7 @@ use Engelsystem\Models\User\Settings; use Engelsystem\Models\User\State; use Engelsystem\Models\User\User; use Engelsystem\Test\Unit\HasDatabase; -use PHPUnit\Framework\TestCase; +use Engelsystem\Test\Unit\TestCase; class UserTest extends TestCase { @@ -95,6 +95,7 @@ class UserTest extends TestCase */ protected function setUp(): void { + parent::setUp(); $this->initDatabase(); } } diff --git a/tests/Unit/Renderer/RendererServiceProviderTest.php b/tests/Unit/Renderer/RendererServiceProviderTest.php index 224e36d4..e655284d 100644 --- a/tests/Unit/Renderer/RendererServiceProviderTest.php +++ b/tests/Unit/Renderer/RendererServiceProviderTest.php @@ -67,15 +67,13 @@ class RendererServiceProviderTest extends ServiceProviderTest $app = $this->getApp(['get', 'tagged']); - $engines = [$engine1, $engine2]; - $this->setExpects($app, 'get', ['renderer'], $renderer); - $this->setExpects($app, 'tagged', ['renderer.engine'], $engines); + $this->setExpects($app, 'tagged', ['renderer.engine'], [$engine1, $engine2]); - $invocation = $renderer - ->expects($this->exactly(count($engines))) - ->method('addRenderer'); - call_user_func_array([$invocation, 'withConsecutive'], $engines); + $renderer + ->expects($this->exactly(2)) + ->method('addRenderer') + ->withConsecutive([$engine1], [$engine2]); $serviceProvider = new RendererServiceProvider($app); $serviceProvider->boot(); diff --git a/tests/Unit/Renderer/Twig/Extensions/LegacyTest.php b/tests/Unit/Renderer/Twig/Extensions/LegacyTest.php index b6c19d14..7190c979 100644 --- a/tests/Unit/Renderer/Twig/Extensions/LegacyTest.php +++ b/tests/Unit/Renderer/Twig/Extensions/LegacyTest.php @@ -26,6 +26,7 @@ class LegacyTest extends ExtensionTest $this->assertExtensionExists('menuUserHints', 'header_render_hints', $functions, $isSafeHtml); $this->assertExtensionExists('menuUserSubmenu', 'make_user_submenu', $functions, $isSafeHtml); $this->assertExtensionExists('page', [$extension, 'getPage'], $functions); + $this->assertExtensionExists('msg', 'msg', $functions, $isSafeHtml); } /** diff --git a/tests/Unit/Renderer/TwigServiceProviderTest.php b/tests/Unit/Renderer/TwigServiceProviderTest.php index 86dee1de..ee4cd971 100644 --- a/tests/Unit/Renderer/TwigServiceProviderTest.php +++ b/tests/Unit/Renderer/TwigServiceProviderTest.php @@ -82,7 +82,7 @@ class TwigServiceProviderTest extends ServiceProviderTest $twig->expects($this->exactly(2)) ->method('addExtension') - ->withConsecutive($firsExtension, $secondExtension); + ->withConsecutive([$firsExtension], [$secondExtension]); $serviceProvider = new TwigServiceProvider($app); $serviceProvider->boot(); diff --git a/tests/Unit/TestCase.php b/tests/Unit/TestCase.php index d09104d4..dba8c989 100644 --- a/tests/Unit/TestCase.php +++ b/tests/Unit/TestCase.php @@ -2,18 +2,22 @@ namespace Engelsystem\Test\Unit; -use PHPUnit\Framework\MockObject\Matcher\InvokedRecorder; +use Engelsystem\Application; use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\MockObject\Rule\InvocationOrder; use PHPUnit\Framework\TestCase as PHPUnitTestCase; abstract class TestCase extends PHPUnitTestCase { + /** @var Application */ + protected $app; + /** * @param MockObject $object * @param string $method * @param array $arguments * @param mixed $return - * @param InvokedRecorder $times + * @param InvocationOrder $times */ protected function setExpects($object, $method, $arguments = null, $return = null, $times = null) { @@ -34,4 +38,12 @@ abstract class TestCase extends PHPUnitTestCase $invocation->willReturn($return); } } + + /** + * Called before each test run + */ + protected function setUp(): void + { + $this->app = new Application(__DIR__ . '/../../'); + } } @@ -3328,7 +3328,7 @@ moment-timezone@^0.4.0: dependencies: moment ">= 2.6.0" -"moment@>= 2.6.0", moment@^2.10, moment@^2.10.2, moment@^2.8.2: +"moment@>= 2.6.0", moment@^2.10, moment@^2.10.2, moment@^2.12.0: version "2.24.0" resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b" integrity sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg== |