diff --git a/.env.example b/.env.example index c1327796c..f197712e4 100644 --- a/.env.example +++ b/.env.example @@ -71,6 +71,7 @@ MIX_WELCOME_MESSAGE_LIMIT=500 MIX_ROOM_NAME_LIMIT=50 DEFAULT_LOGO=/images/logo.svg +DEFAULT_FAVICON=/images/favicon.ico DEFAULT_ROOM_LIMIT=-1 DEFAULT_PAGINATION_PAGE_SIZE=15 OWN_ROOMS_PAGINATION_PAGE_SIZE=5 diff --git a/CHANGELOG.md b/CHANGELOG.md index e4fc42a74..9e396fb38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added setting menu for administrators ([#35], [#38]) - Added a middleware to check whether the request is in sync with model of the database and not stale ([#40], [#41]) - Added management of users and profile page ([#10], [#66]) +- Added management of application settings ([#55], [#60]) [#1]: https://github.com/THM-Health/PILOS/issues/1 [#3]: https://github.com/THM-Health/PILOS/pull/3 @@ -56,6 +57,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#49]: https://github.com/THM-Health/PILOS/pull/49 [#50]: https://github.com/THM-Health/PILOS/issues/50 [#54]: https://github.com/THM-Health/PILOS/pull/54 +[#55]: https://github.com/THM-Health/PILOS/issues/55 +[#60]: https://github.com/THM-Health/PILOS/pull/60 [#66]: https://github.com/THM-Health/PILOS/pull/66 + [unreleased]: https://github.com/THM-Health/PILOS/compare/3c8359cdb0395546fe97aeabf1a40f93002b182c...HEAD diff --git a/app/Http/Controllers/api/v1/ApplicationController.php b/app/Http/Controllers/api/v1/ApplicationController.php index 8523635c9..b5e6424b6 100644 --- a/app/Http/Controllers/api/v1/ApplicationController.php +++ b/app/Http/Controllers/api/v1/ApplicationController.php @@ -3,29 +3,55 @@ namespace App\Http\Controllers\api\v1; use App\Http\Controllers\Controller; +use App\Http\Requests\UpdateSetting; +use App\Http\Resources\ApplicationSettings; use App\Http\Resources\User as UserResource; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Storage; class ApplicationController extends Controller { /** * Load basic application data, like settings - * @return \Illuminate\Http\JsonResponse + * @return ApplicationSettings */ public function settings() { - return response()->json(['data' => [ - 'logo' => setting('logo'), - 'room_limit' => setting('room_limit'), - 'pagination_page_size' => setting('pagination_page_size'), - 'bbb' => [ - 'file_mimes' => config('bigbluebutton.allowed_file_mimes'), - 'max_filesize' => config('bigbluebutton.max_filesize'), - 'room_name_limit' => config('bigbluebutton.room_name_limit'), - 'welcome_message_limit' => config('bigbluebutton.welcome_message_limit') - ] - ] - ]); + return new ApplicationSettings(); + } + + /** + * Update application settings data + * @param UpdateSetting $request + * @return ApplicationSettings + */ + public function updateSettings(UpdateSetting $request) + { + if ($request->has('logo_file')) { + $path = $request->file('logo_file')->store('images', 'public'); + $url = Storage::url($path); + $logo = $url; + } else { + $logo = $request->logo; + } + + if ($request->has('favicon_file')) { + $path = $request->file('favicon_file')->store('images', 'public'); + $url = Storage::url($path); + $favicon = $url; + } else { + $favicon = $request->favicon; + } + + setting()->set('logo', $logo); + setting()->set('favicon', $favicon); + setting()->set('name', $request->name); + setting()->set('room_limit', $request->room_limit); + setting()->set('own_rooms_pagination_page_size', $request->own_rooms_pagination_page_size); + setting()->set('pagination_page_size', $request->pagination_page_size); + setting()->save(); + + return new ApplicationSettings(); } /** diff --git a/app/Http/Requests/UpdateSetting.php b/app/Http/Requests/UpdateSetting.php new file mode 100644 index 000000000..7f06cd8e9 --- /dev/null +++ b/app/Http/Requests/UpdateSetting.php @@ -0,0 +1,27 @@ + 'required|string|max:255', + 'room_limit' => 'required|numeric|min:-1|max:100', + 'logo' => 'required_without:logo_file|string|max:255', + 'logo_file' => 'required_without:logo|image|max:500', // 500 KB, larger files are bad for loading times + 'favicon' => 'required_without:favicon_file|string|max:255', + 'favicon_file' => 'required_without:favicon|mimes:ico|max:500', // 500 KB, larger files are bad for loading times + 'own_rooms_pagination_page_size' => 'required|numeric|min:1|max:25', + 'pagination_page_size' => 'required|numeric|min:1|max:100' + ]; + } +} diff --git a/app/Http/Resources/ApplicationSettings.php b/app/Http/Resources/ApplicationSettings.php new file mode 100644 index 000000000..854b6abaf --- /dev/null +++ b/app/Http/Resources/ApplicationSettings.php @@ -0,0 +1,35 @@ + setting('name'), + 'logo' => setting('logo'), + 'favicon' => setting('favicon'), + 'room_limit' => intval(setting('room_limit')), + 'pagination_page_size' => intval(setting('pagination_page_size')), + 'own_rooms_pagination_page_size' => intval(setting('own_rooms_pagination_page_size')), + 'bbb' => [ + 'file_mimes' => config('bigbluebutton.allowed_file_mimes'), + 'max_filesize' => intval(config('bigbluebutton.max_filesize')), + 'room_name_limit' => intval(config('bigbluebutton.room_name_limit')), + 'welcome_message_limit' => intval(config('bigbluebutton.welcome_message_limit')) + ] + ]; + } +} diff --git a/config/settings.php b/config/settings.php index ef3a5aaeb..c71afde9d 100644 --- a/config/settings.php +++ b/config/settings.php @@ -68,7 +68,9 @@ | */ 'defaults' => [ + 'name' => env('APP_NAME', 'PILOS'), 'logo' => env('DEFAULT_LOGO', '/images/logo.svg'), + 'favicon' => env('DEFAULT_FAVICON', '/images/favicon.ico'), 'room_limit' => env('DEFAULT_ROOM_LIMIT',-1), 'own_rooms_pagination_page_size' => env('OWN_ROOMS_PAGINATION_PAGE_SIZE',5), 'pagination_page_size' => env('DEFAULT_PAGINATION_PAGE_SIZE', 15), diff --git a/database/seeds/RolesAndPermissionsSeeder.php b/database/seeds/RolesAndPermissionsSeeder.php index 6f348920c..79d66ab52 100644 --- a/database/seeds/RolesAndPermissionsSeeder.php +++ b/database/seeds/RolesAndPermissionsSeeder.php @@ -20,12 +20,17 @@ public function run() $adminPermissions[] = Permission::firstOrCreate([ 'name' => 'rooms.create' ])->id; $adminPermissions[] = Permission::firstOrCreate([ 'name' => 'rooms.delete' ])->id; + $adminPermissions[] = Permission::firstOrCreate([ 'name' => 'settings.manage' ])->id; + $adminPermissions[] = Permission::firstOrCreate([ 'name' => 'settings.viewAny' ])->id; + $adminPermissions[] = Permission::firstOrCreate([ 'name' => 'settings.update' ])->id; + $adminPermissions[] = Permission::firstOrCreate([ 'name' => 'roles.viewAny' ])->id; $adminPermissions[] = Permission::firstOrCreate([ 'name' => 'roles.view' ])->id; $adminPermissions[] = Permission::firstOrCreate([ 'name' => 'roles.create' ])->id; $adminPermissions[] = Permission::firstOrCreate([ 'name' => 'roles.update' ])->id; $adminPermissions[] = Permission::firstOrCreate([ 'name' => 'roles.delete' ])->id; + $adminPermissions[] = Permission::firstOrCreate([ 'name' => 'users.viewAny' ])->id; $adminPermissions[] = Permission::firstOrCreate([ 'name' => 'users.view' ])->id; $adminPermissions[] = Permission::firstOrCreate([ 'name' => 'users.create' ])->id; @@ -33,7 +38,10 @@ public function run() $adminPermissions[] = Permission::firstOrCreate([ 'name' => 'users.delete' ])->id; $adminPermissions[] = Permission::firstOrCreate([ 'name' => 'users.updateOwnAttributes' ])->id; - $adminRole = Role::firstOrCreate([ 'name' => 'admin', 'default' => true, 'room_limit' => -1 ]); + $adminRole = Role::where(['name' => 'admin', 'default' => true])->first(); + if ($adminRole == null) { + $adminRole = Role::create([ 'name' => 'admin', 'default' => true, 'room_limit' => -1 ]); + } $adminRole->permissions()->syncWithoutDetaching($adminPermissions); } } diff --git a/resources/images/favicon.ico b/resources/images/favicon.ico new file mode 100644 index 000000000..e621c3b2b Binary files /dev/null and b/resources/images/favicon.ico differ diff --git a/resources/images/favicon.svg b/resources/images/favicon.svg new file mode 100644 index 000000000..de7dc04e9 --- /dev/null +++ b/resources/images/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/js/app.js b/resources/js/app.js index dbdc4c137..97f066625 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -51,6 +51,8 @@ Vue.config.errorHandler = function (error, vm, info) { } else if (responseStatus === env.HTTP_GUESTS_ONLY) { // 420 => only for guests, redirect to home route vm.flashMessage.info(vm.$t('app.flash.guestsOnly')); vm.$router.replace({ name: 'home' }); + } else if (responseStatus === env.HTTP_PAYLOAD_TOO_LARGE) { // 413 => payload to large + vm.flashMessage.error(vm.$t('app.flash.tooLarge')); } else if (responseStatus !== undefined) { // Another error on server vm.flashMessage.error({ message: errorMessage ? vm.$t('app.flash.serverError.message', { message: errorMessage }) : vm.$t('app.flash.serverError.emptyMessage'), diff --git a/resources/js/components/Room/FileComponent.vue b/resources/js/components/Room/FileComponent.vue index 2d0d2e5d1..1867bff21 100644 --- a/resources/js/components/Room/FileComponent.vue +++ b/resources/js/components/Room/FileComponent.vue @@ -25,6 +25,7 @@ { if (error.response) { if (error.response.status === env.HTTP_PAYLOAD_TOO_LARGE) { - this.errors = { file: [this.$t('rooms.files.validation.tooLarge')] }; + this.errors = { file: [this.$t('app.validation.tooLarge')] }; return; } if (error.response.status === env.HTTP_UNPROCESSABLE_ENTITY) { diff --git a/resources/js/lang/de/app.js b/resources/js/lang/de/app.js index 2e74abc88..3619e1ad1 100644 --- a/resources/js/lang/de/app.js +++ b/resources/js/lang/de/app.js @@ -15,6 +15,11 @@ export default { title: 'Fehler' }, + tooLarge: { + message: 'Die übertragenen Daten waren zu groß!', + title: 'Fehler' + }, + guestsOnly: { message: 'Die Anfrage ist nur für nicht angemeldete Nutzer gestattet!', title: 'Nür für Gäste' @@ -59,7 +64,9 @@ export default { settings: { title: 'Einstellungen', - manage: 'Einstellungen verwalten' + manage: 'Einstellungen verwalten', + update: 'Einstellungen bearbeiten', + viewAny: 'Alle Einstellungen anzeigen' }, roles: { @@ -85,9 +92,10 @@ export default { overwrite: 'Überschreiben', save: 'Speichern', back: 'Zurück', - - true: 'Ja', - false: 'Nein', + browse: 'Durchsuchen', + validation: { + tooLarge: 'Die ausgewählte Datei ist zu groß.' + }, nextPage: 'Nächste Seite', previousPage: 'Vorherige Seite', diff --git a/resources/js/lang/de/rooms.js b/resources/js/lang/de/rooms.js index 38e28a98b..567095483 100644 --- a/resources/js/lang/de/rooms.js +++ b/resources/js/lang/de/rooms.js @@ -65,9 +65,6 @@ export default { }, formats: 'Erlaubte Dateiformate: {formats}', size: 'Max. Dateigröße: {size} MB', - validation: { - tooLarge: 'Die ausgewählte Datei ist zu groß.' - }, termsOfUse: { title: 'Nutzungsbedingungen', content: 'Dateien, welche hier zum Download angeboten werden, sind ausschließlich für das persönliche Studium. Die Dateien, oder Inhalte aus diesen, dürfen nicht geteilt oder weiterverbreitet werden.', diff --git a/resources/js/lang/de/settings.js b/resources/js/lang/de/settings.js index 590758d58..3d587f04d 100644 --- a/resources/js/lang/de/settings.js +++ b/resources/js/lang/de/settings.js @@ -73,5 +73,48 @@ export default { confirm: 'Wollen Sie den Benutzer {firstname} {lastname} wirklich löschen?', title: 'Benutzer löschen?' } + }, + + application: { + title: 'Anwendung', + logo: { + title: 'Logo', + uploadTitle: 'Logo hochladen (max. 500 KB)', + urlTitle: 'URL zu Logo-Datei', + description: 'URL zum Logo', + hint: 'https://domain.tld/path/logo.svg', + selectFile: 'Logo-Datei auswählen', + alt: 'Favicon Vorschau' + }, + + favicon: { + title: 'Favicon', + uploadTitle: 'Favicon hochladen (max. 500 KB, Format: .ico)', + urlTitle: 'URL zu Favicon-Datei', + description: 'URL zum Favicon', + hint: 'https://domain.tld/path/favicon.ico', + selectFile: 'Favicon-Datei auswählen', + alt: 'Favicon Vorschau' + }, + + name: { + title: 'Name der Anwendung', + description: 'Ändert den Seitentitel' + }, + + roomLimit: { + title: 'Anzahl der Räume pro Benutzer', + description: 'Begrenzt die Anzahl der Räume, die ein Benutzer haben kann. Diese Einstellung wird von den gruppenspezifischen Grenzen überschrieben.' + }, + + paginationPageSize: { + title: 'Größe der Paginierung', + description: 'Begrenzt die Anzahl der gleichzeitig angezeigten Datensätze in Tabellen' + }, + + ownRoomsPaginationPageSize: { + title: 'Größe der Paginierung für eigene Räume', + description: 'Begrenzt die Anzahl der gleichzeitig angezeigten Räume auf der Startseite' + } } }; diff --git a/resources/js/lang/en/app.js b/resources/js/lang/en/app.js index 8f6d1f688..55775e8ad 100644 --- a/resources/js/lang/en/app.js +++ b/resources/js/lang/en/app.js @@ -15,6 +15,11 @@ export default { title: 'Error' }, + tooLarge: { + message: 'The transmitted data was too large!', + title: 'Error' + }, + guestsOnly: { message: 'The request can only be done by guests!', title: 'Only for guests' @@ -59,7 +64,9 @@ export default { settings: { title: 'Settings', - manage: 'Manage settings' + manage: 'Manage settings', + update: 'Edit settings', + viewAny: 'Show all settings' }, roles: { @@ -81,19 +88,20 @@ export default { updateOwnAttributes: 'Update own firstname, lastname and email' } }, - overwrite: 'Overwrite', save: 'Save', back: 'Back', - true: 'Yes', - false: 'No', - nextPage: 'Next page', previousPage: 'Previous page', confirmPassword: { title: 'Confirm password', description: 'Please confirm your password before continuing!' + }, + + browse: 'Browse', + validation: { + tooLarge: 'The selected file is too large.' } }; diff --git a/resources/js/lang/en/rooms.js b/resources/js/lang/en/rooms.js index e25e3cd1c..5c5472ed3 100644 --- a/resources/js/lang/en/rooms.js +++ b/resources/js/lang/en/rooms.js @@ -65,9 +65,6 @@ export default { }, formats: 'Allowed file formats: {formats}', size: 'Max. file size: {size} MB', - validation: { - tooLarge: 'The selected file is too large.' - }, termsOfUse: { title: 'Terms of Use', content: 'Files that can be downloaded here are for personal study only. The files, or parts of them, may not be shared or distributed.', diff --git a/resources/js/lang/en/settings.js b/resources/js/lang/en/settings.js index 0d2f6881f..1dc6027fe 100644 --- a/resources/js/lang/en/settings.js +++ b/resources/js/lang/en/settings.js @@ -72,5 +72,48 @@ export default { confirm: 'Are you really want to delete the user {firstname} {lastname}?', title: 'Delete user?' } + }, + + application: { + title: 'Application', + logo: { + title: 'Logo', + uploadTitle: 'Upload a logo (max. 500 KB)', + urlTitle: 'URL to logo file', + description: 'Changes the application logo. Enter the image URL', + hint: 'https://domain.tld/path/logo.svg', + selectFile: 'Select logo file', + alt: 'Logo preview' + }, + + favicon: { + title: 'Favicon', + uploadTitle: 'Upload a favicon (max. 500 KB, Format: .ico)', + urlTitle: 'URL to favicon file', + description: 'Changes the application favicon. Enter the favicon URL', + hint: 'https://domain.tld/path/favicon.ico', + selectFile: 'Select favicon file', + alt: 'Favicon preview' + }, + + name: { + title: 'Name of the application', + description: 'Changes the site title' + }, + + roomLimit: { + title: 'Number of rooms per user', + description: 'Limits the number of rooms that a user can have. This setting does not apply to administrators. Enter the value -1 for unlimited number of rooms' + }, + + paginationPageSize: { + title: 'Pagination page size', + description: 'Limits the number of page size for data tables pagination' + }, + + ownRoomsPaginationPageSize: { + title: 'Own rooms pagination page size', + description: 'Limits the number of page size for own rooms pagination' + } } }; diff --git a/resources/js/policies/SettingPolicy.js b/resources/js/policies/SettingPolicy.js index ffc28300e..b99cd8444 100644 --- a/resources/js/policies/SettingPolicy.js +++ b/resources/js/policies/SettingPolicy.js @@ -10,5 +10,25 @@ export default { */ manage (permissionService) { return !permissionService.currentUser ? false : permissionService.currentUser.permissions.includes('settings.manage'); + }, + + /** + * Returns a boolean that indicates whether the user can view all application settings or not. + * + * @param permissionService + * @return {boolean} + */ + viewAny (permissionService) { + return !permissionService.currentUser ? false : permissionService.currentUser.permissions.includes('settings.viewAny'); + }, + + /** + * Returns a boolean that indicates whether the user can update the application settings or not. + * + * @param permissionService + * @return {boolean} + */ + update (permissionService) { + return !permissionService.currentUser ? false : permissionService.currentUser.permissions.includes('settings.update'); } }; diff --git a/resources/js/router.js b/resources/js/router.js index 59a73973c..d8b880490 100644 --- a/resources/js/router.js +++ b/resources/js/router.js @@ -12,6 +12,7 @@ import RolesIndex from './views/settings/roles/Index'; import RolesView from './views/settings/roles/View'; import UsersIndex from './views/settings/users/Index'; import UsersView from './views/settings/users/View'; +import Application from './views/settings/Application'; import SettingsHome from './views/settings/SettingsHome'; import Base from './api/base'; @@ -164,6 +165,18 @@ export const routes = [ }); } } + }, + { + path: 'application', + name: 'settings.application', + component: Application, + meta: { + requiresAuth: true, + accessPermitted: () => Promise.resolve( + PermissionService.can('manage', 'SettingPolicy') && + PermissionService.can('viewAny', 'SettingPolicy') + ) + } } ] }, diff --git a/resources/js/views/settings/Application.vue b/resources/js/views/settings/Application.vue new file mode 100644 index 000000000..44288dc4a --- /dev/null +++ b/resources/js/views/settings/Application.vue @@ -0,0 +1,477 @@ + + + + {{ $t('settings.application.title') }} + + + + + + + + + + {{ $t('app.reload') }} + + + + + + + + + + + + + + + + + + + + + + + + + {{ $t('settings.application.favicon.urlTitle') }} + + + {{ $t('settings.application.favicon.uploadTitle') }} + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ $t('settings.application.logo.urlTitle') }} + + + {{ $t('settings.application.logo.uploadTitle') }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ $t('app.save') }} + + + + + + + + + + diff --git a/resources/js/views/settings/Settings.vue b/resources/js/views/settings/Settings.vue index 5e77694a9..9a5071509 100644 --- a/resources/js/views/settings/Settings.vue +++ b/resources/js/views/settings/Settings.vue @@ -22,6 +22,15 @@ + + + + + + {{ $t('settings.application.title') }} + + + diff --git a/resources/js/views/settings/roles/Index.vue b/resources/js/views/settings/roles/Index.vue index a5a5c1682..51e64c476 100644 --- a/resources/js/views/settings/roles/Index.vue +++ b/resources/js/views/settings/roles/Index.vue @@ -40,7 +40,7 @@ - {{ $t(`app.${data.item.default}`) }} + {{ $t(`app.${data.item.default ? 'yes' : 'no'}`) }} @@ -95,7 +95,7 @@ :busy='deleting' ok-variant='danger' cancel-variant='dark' - :cancel-title="$t('app.false')" + :cancel-title="$t('app.no')" @ok='deleteRole($event)' @cancel='clearRoleToDelete' @close='clearRoleToDelete' @@ -105,7 +105,7 @@ {{ $t('settings.roles.delete.title') }} - {{ $t('app.true') }} + {{ $t('app.yes') }} {{ $t('settings.roles.delete.confirm', { name: $te(`app.roles.${roleToDelete.name}`) ? $t(`app.roles.${roleToDelete.name}`) : roleToDelete.name }) }} diff --git a/resources/js/views/settings/users/Index.vue b/resources/js/views/settings/users/Index.vue index 88f3e0537..93ab44576 100644 --- a/resources/js/views/settings/users/Index.vue +++ b/resources/js/views/settings/users/Index.vue @@ -117,7 +117,7 @@ :busy='deleting' ok-variant='danger' cancel-variant='dark' - :cancel-title="$t('app.false')" + :cancel-title="$t('app.no')" @ok='deleteUser($event)' @cancel='clearUserToDelete' @close='clearUserToDelete' @@ -127,7 +127,7 @@ {{ $t('settings.users.delete.title') }} - {{ $t('app.true') }} + {{ $t('app.yes') }} {{ $t('settings.users.delete.confirm', { firstname: userToDelete.firstname, lastname: userToDelete.lastname }) }} diff --git a/resources/views/application.blade.php b/resources/views/application.blade.php index 3093868e9..63cf25acf 100644 --- a/resources/views/application.blade.php +++ b/resources/views/application.blade.php @@ -6,8 +6,8 @@ - - {{ config('app.name', 'PILOS') }} + + {{ setting('name') }} diff --git a/routes/api.php b/routes/api.php index 576305a8f..c596e8515 100644 --- a/routes/api.php +++ b/routes/api.php @@ -47,6 +47,8 @@ }); Route::middleware('auth:users,ldap')->group(function () { + Route::put('settings', 'ApplicationController@updateSettings')->name('application.update')->middleware('can:settings.update'); + Route::apiResource('roles', 'RoleController'); Route::get('permissions', 'PermissionController@index')->name('permissions.index'); diff --git a/tests/Feature/RoutingTest.php b/tests/Feature/RoutingTest.php index fdad50379..5eb60e9e1 100644 --- a/tests/Feature/RoutingTest.php +++ b/tests/Feature/RoutingTest.php @@ -2,11 +2,14 @@ namespace Tests\Feature; +use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\URL; use Tests\TestCase; class RoutingTest extends TestCase { + use RefreshDatabase; + /** * Tests not found responses on not existing api route. * diff --git a/tests/Feature/api/v1/SettingsTest.php b/tests/Feature/api/v1/SettingsTest.php index e4a7cb643..642481ff4 100644 --- a/tests/Feature/api/v1/SettingsTest.php +++ b/tests/Feature/api/v1/SettingsTest.php @@ -2,14 +2,29 @@ namespace Tests\Feature\api\v1; +use App\Permission; +use App\Role; +use App\User; use Illuminate\Foundation\Testing\WithFaker; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\UploadedFile; use Tests\TestCase; class SettingsTest extends TestCase { use RefreshDatabase, WithFaker; + protected $user; + + /** + * Setup resources for all tests + */ + protected function setUp(): void + { + parent::setUp(); + $this->user = factory(User::class)->create(); + } + /** * Tests that the correct application wide settings provided * @@ -19,9 +34,205 @@ public function testApplicationSettings() { setting(['logo' => 'testlogo.svg']); setting(['pagination_page_size' => '123']); + setting(['own_rooms_pagination_page_size' => '123']); + setting(['room_limit' => '-1']); $this->getJson(route('api.v1.application')) - ->assertJson(['data'=>['logo'=>'testlogo.svg','pagination_page_size'=>'123']]) + ->assertJson([ + 'data' => [ + 'logo' => 'testlogo.svg', + 'pagination_page_size' => '123', + 'own_rooms_pagination_page_size' => '123', + 'room_limit' => '-1', + ] + ]) ->assertSuccessful(); } + + /** + * Tests that updates application settings with valid inputs and image file upload + * + * @return void + */ + public function testUpdateApplicationSettingsWithValidInputsImageFile() + { + $payload = [ + 'name' => 'test', + 'logo_file' => UploadedFile::fake()->image('logo.svg'), + 'favicon_file' => UploadedFile::fake()->create('favicon.ico', 100, 'image/x-icon'), + 'pagination_page_size' => '10', + 'own_rooms_pagination_page_size' => '15', + 'room_limit' => '-1', + ]; + + // Unauthorized Test + $this->putJson(route('api.v1.application.update'), $payload) + ->assertUnauthorized(); + + // Forbidden Test + $this->actingAs($this->user)->putJson(route('api.v1.application.update'), $payload) + ->assertForbidden(); + + // Add necessary role and permission to user to update application settings + $role = factory(Role::class)->create(); + $permission = factory(Permission::class)->create(['name' => 'settings.update']); + $role->permissions()->attach($permission); + $this->user->roles()->attach($role); + + $this->actingAs($this->user)->putJson(route('api.v1.application.update'), $payload) + ->assertSuccessful(); + } + + /** + * Tests that updates application settings with valid inputs and and image url + * + * @return void + */ + public function testUpdateApplicationSettingsWithValidInputsImageUrl() + { + $payload = [ + 'name' => 'test', + 'favicon' => '/storage/image/favicon.ico', + 'logo' => '/storage/image/testfile.svg', + 'pagination_page_size' => '10', + 'own_rooms_pagination_page_size' => '15', + 'room_limit' => '-1', + ]; + + // Unauthorized Test + $this->putJson(route('api.v1.application.update'), $payload) + ->assertUnauthorized(); + + // Forbidden Test + $this->actingAs($this->user)->putJson(route('api.v1.application.update'), $payload) + ->assertForbidden(); + + // Add necessary role and permission to user to update application settings + $role = factory(Role::class)->create(); + $permission = factory(Permission::class)->create(['name' => 'settings.update']); + $role->permissions()->attach($permission); + $this->user->roles()->attach($role); + + $this->actingAs($this->user)->putJson(route('api.v1.application.update'), $payload) + ->assertSuccessful(); + } + + /** + * Tests that updates application settings with valid inputs, having a file url and file upload. + * Uploaded files should have a higher priority and overwrite possible urls + * + * @return void + */ + public function testUpdateApplicationSettingsWithValidInputsImageFileAndUrl() + { + $payload = [ + 'name' => 'test', + 'logo' => '/storage/image/testfile.svg', + 'logo_file' => UploadedFile::fake()->image('logo.svg'), + 'favicon' => '/storage/image/favicon.ico', + 'favicon_file' => UploadedFile::fake()->create('favicon.ico', 100, 'image/x-icon'), + 'pagination_page_size' => '10', + 'own_rooms_pagination_page_size' => '15', + 'room_limit' => '-1', + ]; + + // Add necessary role and permission to user to update application settings + $role = factory(Role::class)->create(); + $permission = factory(Permission::class)->create(['name' => 'settings.update']); + $role->permissions()->attach($permission); + $this->user->roles()->attach($role); + + $response = $this->actingAs($this->user)->putJson(route('api.v1.application.update'), $payload); + $response->assertSuccessful(); + + $this->assertFalse($response->json('data.logo') == '/storage/image/testfile.svg'); + } + + /** + * Tests that updates application settings with invalid inputs + * + * @return void + */ + public function testUpdateApplicationSettingsWithInvalidInputs() + { + // Add necessary role and permission to user to update application settings + $role = factory(Role::class)->create(); + $permission = factory(Permission::class)->create(['name' => 'settings.update']); + $role->permissions()->attach($permission); + $this->user->roles()->attach($role); + + $this->actingAs($this->user)->putJson(route('api.v1.application.update'), + [ + 'name' => '', + 'favicon' => '', + 'favicon_file' => 'notimagefile', + 'logo' => '', + 'logo_file' => 'notimagefile', + 'pagination_page_size' => 'notnumber', + 'own_rooms_pagination_page_size' => 'notnumber', + 'room_limit' => 'notnumber', + ] + ) + ->assertStatus(422) + ->assertJsonValidationErrors([ + 'name', + 'favicon_file', + 'favicon', + 'logo', + 'logo_file', + 'pagination_page_size', + 'own_rooms_pagination_page_size', + 'room_limit' + ]); + } + + /** + * Tests that updates application settings with invalid inputs for numeric input + * + * @return void + */ + public function testUpdateApplicationSettingsMinMax() + { + // Add necessary role and permission to user to update application settings + $role = factory(Role::class)->create(); + $permission = factory(Permission::class)->create(['name' => 'settings.update']); + $role->permissions()->attach($permission); + $this->user->roles()->attach($role); + + // inputs lower than allowed minimum + $this->actingAs($this->user)->putJson(route('api.v1.application.update'), + [ + 'name' => 'test', + 'favicon' => '/storage/image/favicon.ico', + 'logo' => '/storage/image/testfile.svg', + 'pagination_page_size' => '0', + 'own_rooms_pagination_page_size' => '0', + 'room_limit' => '-2', + ] + ) + ->assertStatus(422) + ->assertJsonValidationErrors([ + 'pagination_page_size', + 'own_rooms_pagination_page_size', + 'room_limit' + ]); + + // inputs higher than allowed minimum + $this->putJson(route('api.v1.application.update'), + [ + 'name' => 'test', + 'favicon' => '/storage/image/favicon.ico', + 'logo' => '/storage/image/testfile.svg', + 'pagination_page_size' => '101', + 'own_rooms_pagination_page_size' => '26', + 'room_limit' => '101', + ] + ) + ->assertStatus(422) + ->assertJsonValidationErrors([ + 'pagination_page_size', + 'own_rooms_pagination_page_size', + 'room_limit' + ]); + } } diff --git a/tests/Frontend/Router.spec.js b/tests/Frontend/Router.spec.js index 9a10c8d83..1e9378db7 100644 --- a/tests/Frontend/Router.spec.js +++ b/tests/Frontend/Router.spec.js @@ -10,6 +10,9 @@ const accessPermittedRolesView = routes.filter(route => route.path === '/setting const accessPermittedUsersView = routes.filter(route => route.path === '/settings')[0] .children.filter(route => route.name === 'settings.users.view')[0].meta.accessPermitted; +const accessPermittedSettingsView = routes.filter(route => route.path === '/settings')[0] + .children.filter(route => route.name === 'settings.application')[0].meta.accessPermitted; + describe('Router', function () { beforeEach(function () { moxios.install(); @@ -313,5 +316,31 @@ describe('Router', function () { done(); }); }); + + it('for application settings update view returns true if user has the necessary permissions', function (done) { + const oldUser = PermissionService.currentUser; + + accessPermittedSettingsView().then(result => { + expect(result).toBe(false); + + PermissionService.setCurrentUser({ permissions: ['settings.viewAny'] }); + return accessPermittedSettingsView(); + }).then(result => { + expect(result).toBe(false); + + PermissionService.setCurrentUser({ permissions: ['settings.viewAny', 'settings.update', 'settings.manage'] }); + return accessPermittedSettingsView(); + }).then(result => { + expect(result).toBe(true); + + PermissionService.setCurrentUser({ permissions: ['settings.viewAny', 'settings.manage'] }); + return accessPermittedSettingsView(); + }).then(result => { + expect(result).toBe(true); + + PermissionService.setCurrentUser(oldUser); + done(); + }); + }); }); }); diff --git a/tests/Frontend/policies/SettingPolicy.spec.js b/tests/Frontend/policies/SettingPolicy.spec.js index 7ef1e69a5..49120ec62 100644 --- a/tests/Frontend/policies/SettingPolicy.spec.js +++ b/tests/Frontend/policies/SettingPolicy.spec.js @@ -5,4 +5,14 @@ describe('SettingPolicy', function () { expect(SettingPolicy.manage({ currentUser: { permissions: [] } })).toBe(false); expect(SettingPolicy.manage({ currentUser: { permissions: ['settings.manage'] } })).toBe(true); }); + + it('viewAny returns true if the user has the permission to view all settings', function () { + expect(SettingPolicy.viewAny({ currentUser: { permissions: [] } })).toBe(false); + expect(SettingPolicy.viewAny({ currentUser: { permissions: ['settings.viewAny'] } })).toBe(true); + }); + + it('update returns true if the user has the permission to update settings', function () { + expect(SettingPolicy.update({ currentUser: { permissions: [] } })).toBe(false); + expect(SettingPolicy.update({ currentUser: { permissions: ['settings.update'] } })).toBe(true); + }); }); diff --git a/tests/Frontend/views/settings/Application.spec.js b/tests/Frontend/views/settings/Application.spec.js new file mode 100644 index 000000000..9644f0ab2 --- /dev/null +++ b/tests/Frontend/views/settings/Application.spec.js @@ -0,0 +1,532 @@ +import { createLocalVue, mount } from '@vue/test-utils'; +import moxios from 'moxios'; +import BootstrapVue, { IconsPlugin } from 'bootstrap-vue'; +import sinon from 'sinon'; +import Base from '../../../../resources/js/api/base'; +import Application from '../../../../resources/js/views/settings/Application'; +import Vuex from 'vuex'; +import env from '../../../../resources/js/env.js'; +import PermissionService from '../../../../resources/js/services/PermissionService'; + +const localVue = createLocalVue(); +localVue.use(BootstrapVue); +localVue.use(IconsPlugin); +localVue.use(Vuex); + +const createContainer = (tag = 'div') => { + const container = document.createElement(tag); + document.body.appendChild(container); + return container; +}; + +describe('Application', function () { + beforeEach(function () { + PermissionService.setCurrentUser({ permissions: ['settings.viewAny', 'settings.update', 'settings.manage'] }); + moxios.install(); + }); + + afterEach(function () { + moxios.uninstall(); + }); + + it('getSettings method called, when the view is mounted', function () { + const spy = sinon.spy(Application.methods, 'getSettings'); + + expect(spy.calledOnce).toBeFalsy(); + + mount(Application, { + localVue, + mocks: { + $t: key => key + }, + attachTo: createContainer() + }); + + expect(spy.calledOnce).toBeTruthy(); + }); + + it('getSettings method works properly with response data room_limit is -1', function (done) { + const view = mount(Application, { + localVue, + mocks: { + $t: key => key + }, + attachTo: createContainer() + }); + + moxios.wait(function () { + const request = moxios.requests.mostRecent(); + request.respondWith({ + status: 200, + response: { + data: { + logo: 'test.svg', + room_limit: -1, + pagination_page_size: 10, + own_rooms_pagination_page_size: 5 + } + } + }).then(() => { + return view.vm.$nextTick(); + }).then(() => { + expect(view.vm.$data.settings.logo).toBe('test.svg'); + expect(view.vm.$data.settings.room_limit).toBe(-1); + expect(view.vm.$data.settings.pagination_page_size).toBe(10); + expect(view.vm.$data.settings.own_rooms_pagination_page_size).toBe(5); + expect(view.vm.$data.roomLimitMode).toBe('unlimited'); + done(); + }); + }); + }); + + it('getSettings method works properly with response data room_limit is not -1', function (done) { + const view = mount(Application, { + localVue, + mocks: { + $t: key => key + }, + attachTo: createContainer() + }); + + moxios.wait(function () { + const request = moxios.requests.mostRecent(); + request.respondWith({ + status: 200, + response: { + data: { + logo: 'test.svg', + room_limit: 32, + pagination_page_size: 10, + own_rooms_pagination_page_size: 5 + } + } + }).then(() => { + return view.vm.$nextTick(); + }).then(() => { + expect(view.vm.$data.settings.logo).toBe('test.svg'); + expect(view.vm.$data.settings.room_limit).toBe(32); + expect(view.vm.$data.settings.pagination_page_size).toBe(10); + expect(view.vm.$data.settings.own_rooms_pagination_page_size).toBe(5); + expect(view.vm.$data.roomLimitMode).toBe('custom'); + done(); + }); + }); + }); + + it('updateSettings method works properly with response data room_limit is not -1', function (done) { + const actions = { + getSettings () { + } + }; + + const store = new Vuex.Store({ + modules: + { + session: { actions, namespaced: true } + } + }); + + const view = mount(Application, { + localVue, + store, + mocks: { + $t: key => key + }, + attachTo: createContainer() + }); + + moxios.wait(function () { + const request = moxios.requests.mostRecent(); + request.respondWith({ + status: 200, + response: { + data: { + logo: 'test.svg', + room_limit: 32, + pagination_page_size: 10, + own_rooms_pagination_page_size: 5 + } + } + }).then(() => { + // Save button, which triggers updateSettings method when clicked + const saveSettingsButton = view.find('#application-save-button'); + expect(saveSettingsButton.exists()).toBeTruthy(); + saveSettingsButton.trigger('click'); + return view.vm.$nextTick(); + }).then(() => { + moxios.wait(function () { + const request = moxios.requests.mostRecent(); + request.respondWith({ + status: 200, + response: { + data: { + logo: 'test1.svg', + room_limit: 33, + pagination_page_size: 11, + own_rooms_pagination_page_size: 6 + } + } + }).then(() => { + return view.vm.$nextTick(); + }).then(() => { + expect(view.vm.$data.settings.logo).toBe('test1.svg'); + expect(view.vm.$data.settings.room_limit).toBe(33); + expect(view.vm.$data.settings.pagination_page_size).toBe(11); + expect(view.vm.$data.settings.own_rooms_pagination_page_size).toBe(6); + expect(view.vm.$data.roomLimitMode).toBe('custom'); + expect(view.vm.$data.isBusy).toBeFalsy(); + done(); + }); + }); + }); + }); + }); + + it('updateSettings method works properly with response data room_limit is -1', function (done) { + const actions = { + getSettings () { + } + }; + + const store = new Vuex.Store({ + modules: + { + session: { actions, namespaced: true } + } + }); + + const view = mount(Application, { + localVue, + store, + mocks: { + $t: key => key + }, + attachTo: createContainer() + }); + + moxios.wait(function () { + const request = moxios.requests.mostRecent(); + request.respondWith({ + status: 200, + response: { + data: { + logo: 'test.svg', + room_limit: 32, + pagination_page_size: 10, + own_rooms_pagination_page_size: 5 + } + } + }).then(() => { + // Save button, which triggers updateSettings method when clicked + const saveSettingsButton = view.find('#application-save-button'); + expect(saveSettingsButton.exists()).toBeTruthy(); + saveSettingsButton.trigger('click'); + return view.vm.$nextTick(); + }).then(() => { + moxios.wait(function () { + const request = moxios.requests.mostRecent(); + request.respondWith({ + status: 200, + response: { + data: { + logo: 'test1.svg', + room_limit: -1, + pagination_page_size: 11, + own_rooms_pagination_page_size: 6 + } + } + }).then(() => { + return view.vm.$nextTick(); + }).then(() => { + expect(view.vm.$data.settings.logo).toBe('test1.svg'); + expect(view.vm.$data.settings.room_limit).toBe(-1); + expect(view.vm.$data.settings.pagination_page_size).toBe(11); + expect(view.vm.$data.settings.own_rooms_pagination_page_size).toBe(6); + expect(view.vm.$data.roomLimitMode).toBe('unlimited'); + expect(view.vm.$data.isBusy).toBeFalsy(); + done(); + }); + }); + }); + }); + }); + + it('roomLimitModeChanged method works properly', async function () { + const view = mount(Application, { + localVue, + mocks: { + $t: key => key + }, + attachTo: createContainer() + }); + + // Room limit radio group value set to 'custom' by default + const roomLimitRadioGroup = view.find('#application-room-limit-radio-group'); + expect(roomLimitRadioGroup.exists()).toBeTruthy(); + expect(roomLimitRadioGroup.props('checked')).toBe('custom'); + + // Simulate radio group check to 'unlimited' option, set room limit value to '-1' and hide roomLimitInput + await view.vm.roomLimitModeChanged('unlimited'); + + expect(view.vm.$data.settings.room_limit).toBe(-1); + + // Simulate radio group check back to 'custom' option + await view.vm.roomLimitModeChanged('custom'); + + expect(view.vm.$data.settings.room_limit).toBe(0); + }); + + it('getSettings error handler', function (done) { + const spy = sinon.spy(); + sinon.stub(Base, 'error').callsFake(spy); + + const view = mount(Application, { + localVue, + mocks: { + $t: key => key + }, + attachTo: createContainer() + }); + + moxios.wait(function () { + const request = moxios.requests.mostRecent(); + request.respondWith({ + status: 500, + response: { + message: 'Test' + } + }).then(() => { + view.vm.$nextTick(); + sinon.assert.calledOnce(Base.error); + Base.error.restore(); + done(); + }); + }); + }); + + it('updateSettings error handler', function (done) { + const spy = sinon.spy(); + sinon.stub(Base, 'error').callsFake(spy); + + const view = mount(Application, { + localVue, + mocks: { + $t: key => key + }, + attachTo: createContainer() + }); + + moxios.wait(function () { + const request = moxios.requests.mostRecent(); + request.respondWith({ + status: 200, + response: { + data: { + logo: 'test.svg', + room_limit: 32, + pagination_page_size: 10, + own_rooms_pagination_page_size: 5 + } + } + }).then(() => { + // Save button, which triggers updateSettings method when clicked + const saveSettingsButton = view.find('#application-save-button'); + expect(saveSettingsButton.exists()).toBeTruthy(); + saveSettingsButton.trigger('click'); + return view.vm.$nextTick(); + }).then(() => { + moxios.wait(function () { + const request = moxios.requests.mostRecent(); + request.respondWith({ + status: 500, + response: { + message: 'Test' + } + }).then(() => { + view.vm.$nextTick(); + sinon.assert.calledOnce(Base.error); + Base.error.restore(); + done(); + }); + }); + }); + }); + }); + + it('updateSettings error handler code 413', function (done) { + const spy = sinon.spy(); + sinon.stub(Base, 'error').callsFake(spy); + + const view = mount(Application, { + localVue, + mocks: { + $t: key => key + }, + attachTo: createContainer() + }); + + moxios.wait(function () { + const request = moxios.requests.mostRecent(); + request.respondWith({ + status: 200, + response: { + data: { + logo: 'test.svg', + room_limit: 32, + pagination_page_size: 10, + own_rooms_pagination_page_size: 5 + } + } + }).then(() => { + // Errors data 'logo_file' array is undefined at the beginning + expect(view.vm.$data.errors.logo_file).toBeUndefined(); + + // Save button, which triggers updateSettings method when clicked + const saveSettingsButton = view.find('#application-save-button'); + expect(saveSettingsButton.exists()).toBeTruthy(); + saveSettingsButton.trigger('click'); + return view.vm.$nextTick(); + }).then(() => { + moxios.wait(function () { + const request = moxios.requests.mostRecent(); + request.respondWith({ + status: env.HTTP_PAYLOAD_TOO_LARGE, + response: { + message: 'Test' + } + }).then(() => { + return view.vm.$nextTick(); + }).then(() => { + sinon.assert.calledOnce(Base.error); + Base.error.restore(); + done(); + }); + }); + }); + }); + }); + + it('updateSettings error handler code 422', function (done) { + const spy = sinon.spy(); + sinon.stub(Base, 'error').callsFake(spy); + + const view = mount(Application, { + localVue, + mocks: { + $t: key => key + }, + attachTo: createContainer() + }); + + moxios.wait(function () { + const request = moxios.requests.mostRecent(); + + request.respondWith({ + status: 200, + response: { + data: { + logo: 'test.svg', + room_limit: -1, + pagination_page_size: 10, + own_rooms_pagination_page_size: 5 + } + } + }).then(() => { + // Errors data logo file array is undefined at the beginning + expect(view.vm.$data.errors.pagination_page_size).toBeUndefined(); + + // Save button, which triggers updateSettings method when clicked + const saveSettingsButton = view.find('#application-save-button'); + expect(saveSettingsButton.exists()).toBeTruthy(); + saveSettingsButton.trigger('click'); + + moxios.wait(function () { + const request = moxios.requests.mostRecent(); + request.respondWith({ + status: env.HTTP_UNPROCESSABLE_ENTITY, + response: { + errors: { + logo: ['logo error'], + room_limit: ['room limit error'], + pagination_page_size: ['pagination page size error.'], + own_rooms_pagination_page_size: ['own rooms pagination page size error'] + } + } + }).then(() => { + return view.vm.$nextTick(); + }).then(() => { + // Errors data populated accordingly to error response for this code + expect(view.vm.$data.errors.logo.length).toBeGreaterThan(0); + expect(view.vm.$data.errors.room_limit.length).toBeGreaterThan(0); + expect(view.vm.$data.errors.pagination_page_size.length).toBeGreaterThan(0); + expect(view.vm.$data.errors.own_rooms_pagination_page_size.length).toBeGreaterThan(0); + + Base.error.restore(); + done(); + }); + }); + }); + }); + }); + + it('uploadLogoFile watcher called base64Encode method when value of data props uploadLogoFile changed', async function () { + const view = mount(Application, { + localVue, + mocks: { + $t: key => key + }, + attachTo: createContainer() + }); + + // base64Encode method spy + const spy = sinon.spy(view.vm, 'base64Encode'); + + expect(spy.calledOnce).toBeFalsy(); + + expect(view.vm.$data.uploadLogoFile).toBe(null); + expect(view.vm.$data.uploadLogoFileSrc).toBe(null); + + // Trigger watcher by setting to data props uploadLogoFile, empty array to avoid test warn + await view.setData({ uploadLogoFile: [] }); + + // baseEncode64 method should be called after value change of uploadLogoFileSrc + expect(spy.calledOnce).toBeTruthy(); + + expect(view.vm.$data.uploadFaviconFile).toBe(null); + expect(view.vm.$data.uploadFaviconFileSrc).toBe(null); + + await view.setData({ uploadFaviconFile: [] }); + + expect(spy.calledTwice).toBeTruthy(); + }); + + it('disable edit button if user does not have permission', function (done) { + PermissionService.setCurrentUser({ permissions: ['settings.viewAny', 'settings.manage'] }); + + const actions = { + getSettings () { + } + }; + + const store = new Vuex.Store({ + modules: + { + session: { actions, namespaced: true } + } + }); + + const view = mount(Application, { + localVue, + store, + mocks: { + $t: key => key + }, + attachTo: createContainer() + }); + + // Save button should be missing + const saveSettingsButton = view.find('#application-save-button'); + expect(saveSettingsButton.exists()).toBeFalsy(); + done(); + }); +}); diff --git a/tests/Frontend/views/settings/roles/Index.spec.js b/tests/Frontend/views/settings/roles/Index.spec.js index 8cbd60c48..0926785db 100644 --- a/tests/Frontend/views/settings/roles/Index.spec.js +++ b/tests/Frontend/views/settings/roles/Index.spec.js @@ -62,7 +62,7 @@ describe('RolesIndex', function () { }).then(() => { let html = view.findComponent(BTbody).findComponent(BTr).html(); expect(html).toContain('Test'); - expect(html).toContain('app.false'); + expect(html).toContain('app.no'); expect(html).toContain('1'); view.vm.$root.$emit('bv::refresh::table', 'roles-table'); @@ -90,7 +90,7 @@ describe('RolesIndex', function () { html = view.findComponent(BTbody).findComponent(BTr).html(); expect(html).toContain('app.roles.admin'); - expect(html).toContain('app.true'); + expect(html).toContain('app.yes'); expect(html).toContain('2'); view.destroy();