From a9d0f3676629053422ee5340f8c83888af18d766 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 17 Oct 2023 13:11:10 +0100 Subject: [PATCH 01/12] User: Started cleanup of user self-management - Moved preference views to more general "my-account" area. - Started new layout for my-account with sidebar. - Added MFA to prefeences view (to be moved). --- .../Controllers/UserAccountController.php | 105 ++++++++++++++++++ .../Controllers/UserPreferencesController.php | 82 -------------- lang/en/preferences.php | 4 +- resources/icons/notifications.svg | 1 + .../layouts/parts/header-user-menu.blade.php | 4 +- .../{preferences => account}/index.blade.php | 25 ++++- .../views/users/account/layout.blade.php | 26 +++++ .../users/account/notifications.blade.php | 71 ++++++++++++ .../parts/shortcut-control.blade.php | 0 .../views/users/account/shortcuts.blade.php | 71 ++++++++++++ .../users/preferences/notifications.blade.php | 75 ------------- .../users/preferences/shortcuts.blade.php | 75 ------------- routes/web.php | 13 +-- 13 files changed, 307 insertions(+), 245 deletions(-) create mode 100644 app/Users/Controllers/UserAccountController.php create mode 100644 resources/icons/notifications.svg rename resources/views/users/{preferences => account}/index.blade.php (56%) create mode 100644 resources/views/users/account/layout.blade.php create mode 100644 resources/views/users/account/notifications.blade.php rename resources/views/users/{preferences => account}/parts/shortcut-control.blade.php (100%) create mode 100644 resources/views/users/account/shortcuts.blade.php delete mode 100644 resources/views/users/preferences/notifications.blade.php delete mode 100644 resources/views/users/preferences/shortcuts.blade.php diff --git a/app/Users/Controllers/UserAccountController.php b/app/Users/Controllers/UserAccountController.php new file mode 100644 index 000000000..9152eb5e8 --- /dev/null +++ b/app/Users/Controllers/UserAccountController.php @@ -0,0 +1,105 @@ +isGuest(); + $mfaMethods = $guest ? [] : user()->mfaValues->groupBy('method'); + + return view('users.account.index', [ + 'mfaMethods' => $mfaMethods, + ]); + } + + /** + * Show the user-specific interface shortcuts. + */ + public function showShortcuts() + { + $shortcuts = UserShortcutMap::fromUserPreferences(); + $enabled = setting()->getForCurrentUser('ui-shortcuts-enabled', false); + + $this->setPageTitle(trans('preferences.shortcuts_interface')); + + return view('users.account.shortcuts', [ + 'shortcuts' => $shortcuts, + 'enabled' => $enabled, + ]); + } + + /** + * Update the user-specific interface shortcuts. + */ + public function updateShortcuts(Request $request) + { + $enabled = $request->get('enabled') === 'true'; + $providedShortcuts = $request->get('shortcut', []); + $shortcuts = new UserShortcutMap($providedShortcuts); + + setting()->putForCurrentUser('ui-shortcuts', $shortcuts->toJson()); + setting()->putForCurrentUser('ui-shortcuts-enabled', $enabled); + + $this->showSuccessNotification(trans('preferences.shortcuts_update_success')); + + return redirect('/my-account/shortcuts'); + } + + /** + * Show the notification preferences for the current user. + */ + public function showNotifications(PermissionApplicator $permissions) + { + $this->checkPermission('receive-notifications'); + $this->preventGuestAccess(); + + $preferences = (new UserNotificationPreferences(user())); + + $query = user()->watches()->getQuery(); + $query = $permissions->restrictEntityRelationQuery($query, 'watches', 'watchable_id', 'watchable_type'); + $query = $permissions->filterDeletedFromEntityRelationQuery($query, 'watches', 'watchable_id', 'watchable_type'); + $watches = $query->with('watchable')->paginate(20); + + $this->setPageTitle(trans('preferences.notifications')); + return view('users.account.notifications', [ + 'preferences' => $preferences, + 'watches' => $watches, + ]); + } + + /** + * Update the notification preferences for the current user. + */ + public function updateNotifications(Request $request) + { + $this->checkPermission('receive-notifications'); + $this->preventGuestAccess(); + $data = $this->validate($request, [ + 'preferences' => ['required', 'array'], + 'preferences.*' => ['required', 'string'], + ]); + + $preferences = (new UserNotificationPreferences(user())); + $preferences->updateFromSettingsArray($data['preferences']); + $this->showSuccessNotification(trans('preferences.notifications_update_success')); + + return redirect('/my-account/notifications'); + } +} diff --git a/app/Users/Controllers/UserPreferencesController.php b/app/Users/Controllers/UserPreferencesController.php index 08d65743b..3600dc55f 100644 --- a/app/Users/Controllers/UserPreferencesController.php +++ b/app/Users/Controllers/UserPreferencesController.php @@ -16,88 +16,6 @@ class UserPreferencesController extends Controller ) { } - /** - * Show the overview for user preferences. - */ - public function index() - { - return view('users.preferences.index'); - } - - /** - * Show the user-specific interface shortcuts. - */ - public function showShortcuts() - { - $shortcuts = UserShortcutMap::fromUserPreferences(); - $enabled = setting()->getForCurrentUser('ui-shortcuts-enabled', false); - - $this->setPageTitle(trans('preferences.shortcuts_interface')); - - return view('users.preferences.shortcuts', [ - 'shortcuts' => $shortcuts, - 'enabled' => $enabled, - ]); - } - - /** - * Update the user-specific interface shortcuts. - */ - public function updateShortcuts(Request $request) - { - $enabled = $request->get('enabled') === 'true'; - $providedShortcuts = $request->get('shortcut', []); - $shortcuts = new UserShortcutMap($providedShortcuts); - - setting()->putForCurrentUser('ui-shortcuts', $shortcuts->toJson()); - setting()->putForCurrentUser('ui-shortcuts-enabled', $enabled); - - $this->showSuccessNotification(trans('preferences.shortcuts_update_success')); - - return redirect('/preferences/shortcuts'); - } - - /** - * Show the notification preferences for the current user. - */ - public function showNotifications(PermissionApplicator $permissions) - { - $this->checkPermission('receive-notifications'); - $this->preventGuestAccess(); - - $preferences = (new UserNotificationPreferences(user())); - - $query = user()->watches()->getQuery(); - $query = $permissions->restrictEntityRelationQuery($query, 'watches', 'watchable_id', 'watchable_type'); - $query = $permissions->filterDeletedFromEntityRelationQuery($query, 'watches', 'watchable_id', 'watchable_type'); - $watches = $query->with('watchable')->paginate(20); - - $this->setPageTitle(trans('preferences.notifications')); - return view('users.preferences.notifications', [ - 'preferences' => $preferences, - 'watches' => $watches, - ]); - } - - /** - * Update the notification preferences for the current user. - */ - public function updateNotifications(Request $request) - { - $this->checkPermission('receive-notifications'); - $this->preventGuestAccess(); - $data = $this->validate($request, [ - 'preferences' => ['required', 'array'], - 'preferences.*' => ['required', 'string'], - ]); - - $preferences = (new UserNotificationPreferences(user())); - $preferences->updateFromSettingsArray($data['preferences']); - $this->showSuccessNotification(trans('preferences.notifications_update_success')); - - return redirect('/preferences/notifications'); - } - /** * Update the preferred view format for a list view of the given type. */ diff --git a/lang/en/preferences.php b/lang/en/preferences.php index 118e8ba82..cf1ee2b37 100644 --- a/lang/en/preferences.php +++ b/lang/en/preferences.php @@ -5,10 +5,10 @@ */ return [ - 'preferences' => 'Preferences', + 'my_account' => 'My Account', 'shortcuts' => 'Shortcuts', - 'shortcuts_interface' => 'Interface Keyboard Shortcuts', + 'shortcuts_interface' => 'UI Shortcut Preferences', 'shortcuts_toggle_desc' => 'Here you can enable or disable keyboard system interface shortcuts, used for navigation and actions.', 'shortcuts_customize_desc' => 'You can customize each of the shortcuts below. Just press your desired key combination after selecting the input for a shortcut.', 'shortcuts_toggle_label' => 'Keyboard shortcuts enabled', diff --git a/resources/icons/notifications.svg b/resources/icons/notifications.svg new file mode 100644 index 000000000..52786954d --- /dev/null +++ b/resources/icons/notifications.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/views/layouts/parts/header-user-menu.blade.php b/resources/views/layouts/parts/header-user-menu.blade.php index 8ba750f95..694b7b87a 100644 --- a/resources/views/layouts/parts/header-user-menu.blade.php +++ b/resources/views/layouts/parts/header-user-menu.blade.php @@ -35,9 +35,9 @@

  • - + @icon('user-preferences') -
    {{ trans('preferences.preferences') }}
    +
    {{ trans('preferences.my_account') }}
  • diff --git a/resources/views/users/preferences/index.blade.php b/resources/views/users/account/index.blade.php similarity index 56% rename from resources/views/users/preferences/index.blade.php rename to resources/views/users/account/index.blade.php index f8576ed9e..11a5bc9c9 100644 --- a/resources/views/users/preferences/index.blade.php +++ b/resources/views/users/account/index.blade.php @@ -9,7 +9,7 @@

    {{ trans('preferences.shortcuts_overview_desc') }}

    @@ -20,7 +20,7 @@

    {{ trans('preferences.notifications_desc') }}

    @endif @@ -37,5 +37,26 @@ @endif + @if(!user()->isGuest()) +
    +
    +

    {{ trans('settings.users_mfa') }}

    +

    {{ trans('settings.users_mfa_desc') }}

    +

    + @if ($mfaMethods->count() > 0) + @icon('check-circle') + @else + @icon('cancel') + @endif + {{ trans_choice('settings.users_mfa_x_methods', $mfaMethods->count()) }} +

    +
    + +
    + @endif + @stop diff --git a/resources/views/users/account/layout.blade.php b/resources/views/users/account/layout.blade.php new file mode 100644 index 000000000..9eaa1eca1 --- /dev/null +++ b/resources/views/users/account/layout.blade.php @@ -0,0 +1,26 @@ +@extends('layouts.simple') + +@section('body') + +@stop \ No newline at end of file diff --git a/resources/views/users/account/notifications.blade.php b/resources/views/users/account/notifications.blade.php new file mode 100644 index 000000000..b3b082bd7 --- /dev/null +++ b/resources/views/users/account/notifications.blade.php @@ -0,0 +1,71 @@ +@extends('users.account.layout') + +@section('main') +
    +
    + {{ method_field('put') }} + {{ csrf_field() }} + +

    {{ trans('preferences.notifications') }}

    +

    {{ trans('preferences.notifications_desc') }}

    + +
    +
    +
    + @include('form.toggle-switch', [ + 'name' => 'preferences[own-page-changes]', + 'value' => $preferences->notifyOnOwnPageChanges(), + 'label' => trans('preferences.notifications_opt_own_page_changes'), + ]) +
    + @if (!setting('app-disable-comments')) +
    + @include('form.toggle-switch', [ + 'name' => 'preferences[own-page-comments]', + 'value' => $preferences->notifyOnOwnPageComments(), + 'label' => trans('preferences.notifications_opt_own_page_comments'), + ]) +
    +
    + @include('form.toggle-switch', [ + 'name' => 'preferences[comment-replies]', + 'value' => $preferences->notifyOnCommentReplies(), + 'label' => trans('preferences.notifications_opt_comment_replies'), + ]) +
    + @endif +
    + +
    + +
    +
    + +
    +
    + +
    +

    {{ trans('preferences.notifications_watched') }}

    +

    {{ trans('preferences.notifications_watched_desc') }}

    + + @if($watches->isEmpty()) +

    {{ trans('common.no_items') }}

    + @else +
    + @foreach($watches as $watch) +
    +
    + @include('entities.icon-link', ['entity' => $watch->watchable]) +
    +
    + @icon('watch' . ($watch->ignoring() ? '-ignore' : '')) + {{ trans('entities.watch_title_' . $watch->getLevelName()) }} +
    +
    + @endforeach +
    + @endif + +
    {{ $watches->links() }}
    +
    +@stop diff --git a/resources/views/users/preferences/parts/shortcut-control.blade.php b/resources/views/users/account/parts/shortcut-control.blade.php similarity index 100% rename from resources/views/users/preferences/parts/shortcut-control.blade.php rename to resources/views/users/account/parts/shortcut-control.blade.php diff --git a/resources/views/users/account/shortcuts.blade.php b/resources/views/users/account/shortcuts.blade.php new file mode 100644 index 000000000..d7d0f23ba --- /dev/null +++ b/resources/views/users/account/shortcuts.blade.php @@ -0,0 +1,71 @@ +@extends('users.account.layout') + +@section('main') +
    +
    + {{ method_field('put') }} + {{ csrf_field() }} + +

    {{ trans('preferences.shortcuts_interface') }}

    + +
    +

    + {{ trans('preferences.shortcuts_toggle_desc') }} + {{ trans('preferences.shortcuts_customize_desc') }} +

    +
    + @include('form.toggle-switch', [ + 'name' => 'enabled', + 'value' => $enabled, + 'label' => trans('preferences.shortcuts_toggle_label'), + ]) +
    +
    + +
    + +

    {{ trans('preferences.shortcuts_section_navigation') }}

    +
    +
    + @include('users.account.parts.shortcut-control', ['label' => trans('common.homepage'), 'id' => 'home_view']) + @include('users.account.parts.shortcut-control', ['label' => trans('entities.shelves'), 'id' => 'shelves_view']) + @include('users.account.parts.shortcut-control', ['label' => trans('entities.books'), 'id' => 'books_view']) + @include('users.account.parts.shortcut-control', ['label' => trans('settings.settings'), 'id' => 'settings_view']) + @include('users.account.parts.shortcut-control', ['label' => trans('entities.my_favourites'), 'id' => 'favourites_view']) +
    +
    + @include('users.account.parts.shortcut-control', ['label' => trans('common.view_profile'), 'id' => 'profile_view']) + @include('users.account.parts.shortcut-control', ['label' => trans('auth.logout'), 'id' => 'logout']) + @include('users.account.parts.shortcut-control', ['label' => trans('common.global_search'), 'id' => 'global_search']) + @include('users.account.parts.shortcut-control', ['label' => trans('common.next'), 'id' => 'next']) + @include('users.account.parts.shortcut-control', ['label' => trans('common.previous'), 'id' => 'previous']) +
    +
    + +

    {{ trans('preferences.shortcuts_section_actions') }}

    +
    +
    + @include('users.account.parts.shortcut-control', ['label' => trans('common.new'), 'id' => 'new']) + @include('users.account.parts.shortcut-control', ['label' => trans('common.edit'), 'id' => 'edit']) + @include('users.account.parts.shortcut-control', ['label' => trans('common.copy'), 'id' => 'copy']) + @include('users.account.parts.shortcut-control', ['label' => trans('common.delete'), 'id' => 'delete']) + @include('users.account.parts.shortcut-control', ['label' => trans('common.favourite'), 'id' => 'favourite']) +
    +
    + @include('users.account.parts.shortcut-control', ['label' => trans('entities.export'), 'id' => 'export']) + @include('users.account.parts.shortcut-control', ['label' => trans('common.sort'), 'id' => 'sort']) + @include('users.account.parts.shortcut-control', ['label' => trans('entities.permissions'), 'id' => 'permissions']) + @include('users.account.parts.shortcut-control', ['label' => trans('common.move'), 'id' => 'move']) + @include('users.account.parts.shortcut-control', ['label' => trans('entities.revisions'), 'id' => 'revisions']) +
    +
    + +

    {{ trans('preferences.shortcuts_overlay_desc') }}

    + +
    + +
    + +
    +
    +@stop diff --git a/resources/views/users/preferences/notifications.blade.php b/resources/views/users/preferences/notifications.blade.php deleted file mode 100644 index 9817aac4d..000000000 --- a/resources/views/users/preferences/notifications.blade.php +++ /dev/null @@ -1,75 +0,0 @@ -@extends('layouts.simple') - -@section('body') -
    - -
    -
    - {{ method_field('put') }} - {{ csrf_field() }} - -

    {{ trans('preferences.notifications') }}

    -

    {{ trans('preferences.notifications_desc') }}

    - -
    -
    -
    - @include('form.toggle-switch', [ - 'name' => 'preferences[own-page-changes]', - 'value' => $preferences->notifyOnOwnPageChanges(), - 'label' => trans('preferences.notifications_opt_own_page_changes'), - ]) -
    - @if (!setting('app-disable-comments')) -
    - @include('form.toggle-switch', [ - 'name' => 'preferences[own-page-comments]', - 'value' => $preferences->notifyOnOwnPageComments(), - 'label' => trans('preferences.notifications_opt_own_page_comments'), - ]) -
    -
    - @include('form.toggle-switch', [ - 'name' => 'preferences[comment-replies]', - 'value' => $preferences->notifyOnCommentReplies(), - 'label' => trans('preferences.notifications_opt_comment_replies'), - ]) -
    - @endif -
    - -
    - -
    -
    - -
    -
    - -
    -

    {{ trans('preferences.notifications_watched') }}

    -

    {{ trans('preferences.notifications_watched_desc') }}

    - - @if($watches->isEmpty()) -

    {{ trans('common.no_items') }}

    - @else -
    - @foreach($watches as $watch) -
    -
    - @include('entities.icon-link', ['entity' => $watch->watchable]) -
    -
    - @icon('watch' . ($watch->ignoring() ? '-ignore' : '')) - {{ trans('entities.watch_title_' . $watch->getLevelName()) }} -
    -
    - @endforeach -
    - @endif - -
    {{ $watches->links() }}
    -
    - -
    -@stop diff --git a/resources/views/users/preferences/shortcuts.blade.php b/resources/views/users/preferences/shortcuts.blade.php deleted file mode 100644 index 677892bc6..000000000 --- a/resources/views/users/preferences/shortcuts.blade.php +++ /dev/null @@ -1,75 +0,0 @@ -@extends('layouts.simple') - -@section('body') -
    - -
    -
    - {{ method_field('put') }} - {{ csrf_field() }} - -

    {{ trans('preferences.shortcuts_interface') }}

    - -
    -

    - {{ trans('preferences.shortcuts_toggle_desc') }} - {{ trans('preferences.shortcuts_customize_desc') }} -

    -
    - @include('form.toggle-switch', [ - 'name' => 'enabled', - 'value' => $enabled, - 'label' => trans('preferences.shortcuts_toggle_label'), - ]) -
    -
    - -
    - -

    {{ trans('preferences.shortcuts_section_navigation') }}

    -
    -
    - @include('users.preferences.parts.shortcut-control', ['label' => trans('common.homepage'), 'id' => 'home_view']) - @include('users.preferences.parts.shortcut-control', ['label' => trans('entities.shelves'), 'id' => 'shelves_view']) - @include('users.preferences.parts.shortcut-control', ['label' => trans('entities.books'), 'id' => 'books_view']) - @include('users.preferences.parts.shortcut-control', ['label' => trans('settings.settings'), 'id' => 'settings_view']) - @include('users.preferences.parts.shortcut-control', ['label' => trans('entities.my_favourites'), 'id' => 'favourites_view']) -
    -
    - @include('users.preferences.parts.shortcut-control', ['label' => trans('common.view_profile'), 'id' => 'profile_view']) - @include('users.preferences.parts.shortcut-control', ['label' => trans('auth.logout'), 'id' => 'logout']) - @include('users.preferences.parts.shortcut-control', ['label' => trans('common.global_search'), 'id' => 'global_search']) - @include('users.preferences.parts.shortcut-control', ['label' => trans('common.next'), 'id' => 'next']) - @include('users.preferences.parts.shortcut-control', ['label' => trans('common.previous'), 'id' => 'previous']) -
    -
    - -

    {{ trans('preferences.shortcuts_section_actions') }}

    -
    -
    - @include('users.preferences.parts.shortcut-control', ['label' => trans('common.new'), 'id' => 'new']) - @include('users.preferences.parts.shortcut-control', ['label' => trans('common.edit'), 'id' => 'edit']) - @include('users.preferences.parts.shortcut-control', ['label' => trans('common.copy'), 'id' => 'copy']) - @include('users.preferences.parts.shortcut-control', ['label' => trans('common.delete'), 'id' => 'delete']) - @include('users.preferences.parts.shortcut-control', ['label' => trans('common.favourite'), 'id' => 'favourite']) -
    -
    - @include('users.preferences.parts.shortcut-control', ['label' => trans('entities.export'), 'id' => 'export']) - @include('users.preferences.parts.shortcut-control', ['label' => trans('common.sort'), 'id' => 'sort']) - @include('users.preferences.parts.shortcut-control', ['label' => trans('entities.permissions'), 'id' => 'permissions']) - @include('users.preferences.parts.shortcut-control', ['label' => trans('common.move'), 'id' => 'move']) - @include('users.preferences.parts.shortcut-control', ['label' => trans('entities.revisions'), 'id' => 'revisions']) -
    -
    - -

    {{ trans('preferences.shortcuts_overlay_desc') }}

    - -
    - -
    - -
    -
    - -
    -@stop diff --git a/routes/web.php b/routes/web.php index 06dffa636..df845bfbc 100644 --- a/routes/web.php +++ b/routes/web.php @@ -232,18 +232,17 @@ Route::middleware('auth')->group(function () { Route::put('/settings/users/{id}', [UserControllers\UserController::class, 'update']); Route::delete('/settings/users/{id}', [UserControllers\UserController::class, 'destroy']); - // User Preferences - Route::get('/preferences', [UserControllers\UserPreferencesController::class, 'index']); - Route::get('/preferences/shortcuts', [UserControllers\UserPreferencesController::class, 'showShortcuts']); - Route::put('/preferences/shortcuts', [UserControllers\UserPreferencesController::class, 'updateShortcuts']); - Route::get('/preferences/notifications', [UserControllers\UserPreferencesController::class, 'showNotifications']); - Route::put('/preferences/notifications', [UserControllers\UserPreferencesController::class, 'updateNotifications']); + // User Account + Route::get('/my-account', [UserControllers\UserAccountController::class, 'index']); + Route::get('/my-account/shortcuts', [UserControllers\UserAccountController::class, 'showShortcuts']); + Route::put('/my-account/shortcuts', [UserControllers\UserAccountController::class, 'updateShortcuts']); + Route::get('/my-account/notifications', [UserControllers\UserAccountController::class, 'showNotifications']); + Route::put('/my-account/notifications', [UserControllers\UserAccountController::class, 'updateNotifications']); Route::patch('/preferences/change-view/{type}', [UserControllers\UserPreferencesController::class, 'changeView']); Route::patch('/preferences/change-sort/{type}', [UserControllers\UserPreferencesController::class, 'changeSort']); Route::patch('/preferences/change-expansion/{type}', [UserControllers\UserPreferencesController::class, 'changeExpansion']); Route::patch('/preferences/toggle-dark-mode', [UserControllers\UserPreferencesController::class, 'toggleDarkMode']); Route::patch('/preferences/update-code-language-favourite', [UserControllers\UserPreferencesController::class, 'updateCodeLanguageFavourite']); - Route::patch('/preferences/update-boolean', [UserControllers\UserPreferencesController::class, 'updateBooleanPreference']); // User API Tokens Route::get('/settings/users/{userId}/create-api-token', [UserApiTokenController::class, 'create']); From a868012048215d9ad080eee3e7bd66cfe9b1beaf Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 17 Oct 2023 17:38:07 +0100 Subject: [PATCH 02/12] Users: Built out auth page for my-account section --- app/Access/SocialAuthService.php | 1 + .../Controllers/UserAccountController.php | 54 ++++++++++-- lang/en/preferences.php | 6 ++ lang/en/settings.php | 4 +- resources/icons/security.svg | 1 + resources/views/settings/layout.blade.php | 2 +- resources/views/users/account/auth.blade.php | 87 +++++++++++++++++++ .../views/users/account/layout.blade.php | 7 +- .../users/api-tokens/parts/list.blade.php | 1 + resources/views/users/edit.blade.php | 36 ++++---- resources/views/users/parts/form.blade.php | 2 +- routes/web.php | 2 + 12 files changed, 174 insertions(+), 29 deletions(-) create mode 100644 resources/icons/security.svg create mode 100644 resources/views/users/account/auth.blade.php diff --git a/app/Access/SocialAuthService.php b/app/Access/SocialAuthService.php index 24a04ef7e..fe9195430 100644 --- a/app/Access/SocialAuthService.php +++ b/app/Access/SocialAuthService.php @@ -214,6 +214,7 @@ class SocialAuthService /** * Gets the names of the active social drivers. + * @returns array */ public function getActiveDrivers(): array { diff --git a/app/Users/Controllers/UserAccountController.php b/app/Users/Controllers/UserAccountController.php index 9152eb5e8..3dd13b851 100644 --- a/app/Users/Controllers/UserAccountController.php +++ b/app/Users/Controllers/UserAccountController.php @@ -2,18 +2,25 @@ namespace BookStack\Users\Controllers; +use BookStack\Access\SocialAuthService; use BookStack\Http\Controller; use BookStack\Permissions\PermissionApplicator; use BookStack\Settings\UserNotificationPreferences; use BookStack\Settings\UserShortcutMap; use BookStack\Users\UserRepo; +use Closure; use Illuminate\Http\Request; +use Illuminate\Validation\Rules\Password; class UserAccountController extends Controller { public function __construct( - protected UserRepo $userRepo + protected UserRepo $userRepo, ) { + $this->middleware(function (Request $request, Closure $next) { + $this->preventGuestAccess(); + return $next($request); + }); } /** @@ -21,8 +28,7 @@ class UserAccountController extends Controller */ public function index() { - $guest = user()->isGuest(); - $mfaMethods = $guest ? [] : user()->mfaValues->groupBy('method'); + $mfaMethods = user()->mfaValues->groupBy('method'); return view('users.account.index', [ 'mfaMethods' => $mfaMethods, @@ -40,6 +46,7 @@ class UserAccountController extends Controller $this->setPageTitle(trans('preferences.shortcuts_interface')); return view('users.account.shortcuts', [ + 'category' => 'shortcuts', 'shortcuts' => $shortcuts, 'enabled' => $enabled, ]); @@ -68,7 +75,6 @@ class UserAccountController extends Controller public function showNotifications(PermissionApplicator $permissions) { $this->checkPermission('receive-notifications'); - $this->preventGuestAccess(); $preferences = (new UserNotificationPreferences(user())); @@ -79,6 +85,7 @@ class UserAccountController extends Controller $this->setPageTitle(trans('preferences.notifications')); return view('users.account.notifications', [ + 'category' => 'notifications', 'preferences' => $preferences, 'watches' => $watches, ]); @@ -90,7 +97,6 @@ class UserAccountController extends Controller public function updateNotifications(Request $request) { $this->checkPermission('receive-notifications'); - $this->preventGuestAccess(); $data = $this->validate($request, [ 'preferences' => ['required', 'array'], 'preferences.*' => ['required', 'string'], @@ -102,4 +108,42 @@ class UserAccountController extends Controller return redirect('/my-account/notifications'); } + + /** + * Show the view for the "Access & Security" account options. + */ + public function showAuth(SocialAuthService $socialAuthService) + { + $mfaMethods = user()->mfaValues->groupBy('method'); + + $this->setPageTitle(trans('preferences.auth')); + + return view('users.account.auth', [ + 'category' => 'auth', + 'mfaMethods' => $mfaMethods, + 'authMethod' => config('auth.method'), + 'activeSocialDrivers' => $socialAuthService->getActiveDrivers(), + ]); + } + + /** + * Handle the submission for the auth change password form. + */ + public function updatePassword(Request $request) + { + if (config('auth.method') !== 'standard') { + $this->showPermissionError(); + } + + $validated = $this->validate($request, [ + 'password' => ['required_with:password_confirm', Password::default()], + 'password-confirm' => ['same:password', 'required_with:password'], + ]); + + $this->userRepo->update(user(), $validated, false); + + $this->showSuccessNotification(trans('preferences.auth_change_password_success')); + + return redirect('/my-account/auth'); + } } diff --git a/lang/en/preferences.php b/lang/en/preferences.php index cf1ee2b37..d112b9ebb 100644 --- a/lang/en/preferences.php +++ b/lang/en/preferences.php @@ -29,5 +29,11 @@ return [ 'notifications_watched' => 'Watched & Ignored Items', 'notifications_watched_desc' => ' Below are the items that have custom watch preferences applied. To update your preferences for these, view the item then find the watch options in the sidebar.', + 'auth' => 'Access & Security', + 'auth_change_password' => 'Change Password', + 'auth_change_password_desc' => 'Change the password you use to log-in to the application. This must be at least 8 characters long.', + 'auth_change_password_success' => 'Password has been updated!', + + 'profile' => 'Profile Details', 'profile_overview_desc' => ' Manage your user profile details including preferred language and authentication options.', ]; diff --git a/lang/en/settings.php b/lang/en/settings.php index 9f60606ac..579c4b5c8 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -194,7 +194,7 @@ return [ 'users_send_invite_option' => 'Send user invite email', 'users_external_auth_id' => 'External Authentication ID', 'users_external_auth_id_desc' => 'This is the ID used to match this user when communicating with your external authentication system.', - 'users_password_warning' => 'Only fill the below if you would like to change your password.', + 'users_password_warning' => 'Only fill the below if you would like to change the password for this user.', 'users_system_public' => 'This user represents any guest users that visit your instance. It cannot be used to log in but is assigned automatically.', 'users_delete' => 'Delete User', 'users_delete_named' => 'Delete user :userName', @@ -210,12 +210,14 @@ return [ 'users_preferred_language' => 'Preferred Language', 'users_preferred_language_desc' => 'This option will change the language used for the user-interface of the application. This will not affect any user-created content.', 'users_social_accounts' => 'Social Accounts', + 'users_social_accounts_desc' => 'View the status of the connected social accounts for this user. Social accounts can be used in addition to the primary authentication system for system access.', 'users_social_accounts_info' => 'Here you can connect your other accounts for quicker and easier login. Disconnecting an account here does not revoke previously authorized access. Revoke access from your profile settings on the connected social account.', 'users_social_connect' => 'Connect Account', 'users_social_disconnect' => 'Disconnect Account', 'users_social_connected' => ':socialAccount account was successfully attached to your profile.', 'users_social_disconnected' => ':socialAccount account was successfully disconnected from your profile.', 'users_api_tokens' => 'API Tokens', + 'users_api_tokens_desc' => 'Create and manage the access tokens used to authenticate with the BookStack REST API. Permissions for the API are managed via the user that the token belongs to.', 'users_api_tokens_none' => 'No API tokens have been created for this user', 'users_api_tokens_create' => 'Create Token', 'users_api_tokens_expires' => 'Expires', diff --git a/resources/icons/security.svg b/resources/icons/security.svg new file mode 100644 index 000000000..4fc0d20b9 --- /dev/null +++ b/resources/icons/security.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/views/settings/layout.blade.php b/resources/views/settings/layout.blade.php index 7c6f8b002..94a8f7725 100644 --- a/resources/views/settings/layout.blade.php +++ b/resources/views/settings/layout.blade.php @@ -12,7 +12,7 @@
    {{ trans('settings.system_version') }}
    diff --git a/resources/views/users/account/auth.blade.php b/resources/views/users/account/auth.blade.php new file mode 100644 index 000000000..3503978cf --- /dev/null +++ b/resources/views/users/account/auth.blade.php @@ -0,0 +1,87 @@ +@extends('users.account.layout') + +@section('main') + + @if($authMethod === 'standard') +
    +
    + {{ method_field('put') }} + {{ csrf_field() }} + +

    {{ trans('preferences.auth_change_password') }}

    + +

    + {{ trans('preferences.auth_change_password_desc') }} +

    + +
    +
    + + @include('form.password', ['name' => 'password', 'autocomplete' => 'new-password']) +
    +
    + + @include('form.password', ['name' => 'password-confirm']) +
    +
    + +
    + +
    + +
    +
    + @endif + +
    +
    +

    {{ trans('settings.users_mfa') }}

    +

    {{ trans('settings.users_mfa_desc') }}

    +

    + @if ($mfaMethods->count() > 0) + @icon('check-circle') + @else + @icon('cancel') + @endif + {{ trans_choice('settings.users_mfa_x_methods', $mfaMethods->count()) }} +

    +
    + +
    + + @if(count($activeSocialDrivers) > 0) +
    +

    {{ trans('settings.users_social_accounts') }}

    +

    {{ trans('settings.users_social_accounts_info') }}

    +
    +
    + @foreach($activeSocialDrivers as $driver => $enabled) +
    +
    @icon('auth/'. $driver, ['style' => 'width: 56px;height: 56px;'])
    +
    + @if(user()->hasSocialAccount($driver)) +
    + {{ csrf_field() }} + +
    + @else + {{ trans('settings.users_social_connect') }} + @endif +
    +
    + @endforeach +
    +
    +
    + @endif + + @if(userCan('access-api')) + @include('users.api-tokens.parts.list', ['user' => user()]) + @endif +@stop diff --git a/resources/views/users/account/layout.blade.php b/resources/views/users/account/layout.blade.php index 9eaa1eca1..ff5ad3622 100644 --- a/resources/views/users/account/layout.blade.php +++ b/resources/views/users/account/layout.blade.php @@ -9,9 +9,10 @@ diff --git a/resources/views/users/api-tokens/parts/list.blade.php b/resources/views/users/api-tokens/parts/list.blade.php index 58617fb85..3081682a4 100644 --- a/resources/views/users/api-tokens/parts/list.blade.php +++ b/resources/views/users/api-tokens/parts/list.blade.php @@ -8,6 +8,7 @@ @endif +

    {{ trans('settings.users_api_tokens_desc') }}

    @if (count($user->apiTokens) > 0)
    @foreach($user->apiTokens as $token) diff --git a/resources/views/users/edit.blade.php b/resources/views/users/edit.blade.php index 832186930..e6b477a12 100644 --- a/resources/views/users/edit.blade.php +++ b/resources/views/users/edit.blade.php @@ -51,7 +51,7 @@

    {{ trans('settings.users_mfa') }}

    -

    {{ trans('settings.users_mfa_desc') }}

    +

    {{ trans('settings.users_mfa_desc') }}

    @if ($mfaMethods->count() > 0) @@ -71,28 +71,28 @@
    - @if(user()->id === $user->id && count($activeSocialDrivers) > 0) + @if(count($activeSocialDrivers) > 0)
    -

    {{ trans('settings.users_social_accounts') }}

    -

    {{ trans('settings.users_social_accounts_info') }}

    +
    +

    {{ trans('settings.users_social_accounts') }}

    +
    + @if(user()->id === $user->id) + {{ trans('common.manage') }} + @endif +
    +
    +

    {{ trans('settings.users_social_accounts_desc') }}

    - @foreach($activeSocialDrivers as $driver => $enabled) + @foreach($activeSocialDrivers as $driver => $driverName)
    @icon('auth/'. $driver, ['style' => 'width: 56px;height: 56px;'])
    -
    - @if($user->hasSocialAccount($driver)) -
    - {{ csrf_field() }} - -
    - @else - {{ trans('settings.users_social_connect') }} - @endif -
    +

    {{ $driverName }}

    + @if($user->hasSocialAccount($driver)) +

    Connected

    + @else +

    Disconnected

    + @endif
    @endforeach
    diff --git a/resources/views/users/parts/form.blade.php b/resources/views/users/parts/form.blade.php index 7ff48a83d..d9f958837 100644 --- a/resources/views/users/parts/form.blade.php +++ b/resources/views/users/parts/form.blade.php @@ -64,7 +64,7 @@ @endif
    -

    {{ trans('settings.users_password_desc') }}

    +

    {{ trans('settings.users_password_desc') }}

    @if(isset($model))

    {{ trans('settings.users_password_warning') }} diff --git a/routes/web.php b/routes/web.php index df845bfbc..a7d3534bd 100644 --- a/routes/web.php +++ b/routes/web.php @@ -238,6 +238,8 @@ Route::middleware('auth')->group(function () { Route::put('/my-account/shortcuts', [UserControllers\UserAccountController::class, 'updateShortcuts']); Route::get('/my-account/notifications', [UserControllers\UserAccountController::class, 'showNotifications']); Route::put('/my-account/notifications', [UserControllers\UserAccountController::class, 'updateNotifications']); + Route::get('/my-account/auth', [UserControllers\UserAccountController::class, 'showAuth']); + Route::put('/my-account/auth/password', [UserControllers\UserAccountController::class, 'updatePassword']); Route::patch('/preferences/change-view/{type}', [UserControllers\UserPreferencesController::class, 'changeView']); Route::patch('/preferences/change-sort/{type}', [UserControllers\UserPreferencesController::class, 'changeSort']); Route::patch('/preferences/change-expansion/{type}', [UserControllers\UserPreferencesController::class, 'changeExpansion']); From c1b01639c10ce28fb4cc80b5d9a4b988b5d29a3e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 18 Oct 2023 12:39:57 +0100 Subject: [PATCH 03/12] My Account: Built out profile page & endpoints Text currently hard-coded, needs finalising and extracting. --- .../Controllers/UserAccountController.php | 47 ++++++++ lang/en/preferences.php | 2 +- .../views/users/account/profile.blade.php | 107 ++++++++++++++++++ routes/web.php | 2 + 4 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 resources/views/users/account/profile.blade.php diff --git a/app/Users/Controllers/UserAccountController.php b/app/Users/Controllers/UserAccountController.php index 3dd13b851..83e942b04 100644 --- a/app/Users/Controllers/UserAccountController.php +++ b/app/Users/Controllers/UserAccountController.php @@ -7,6 +7,7 @@ use BookStack\Http\Controller; use BookStack\Permissions\PermissionApplicator; use BookStack\Settings\UserNotificationPreferences; use BookStack\Settings\UserShortcutMap; +use BookStack\Uploads\ImageRepo; use BookStack\Users\UserRepo; use Closure; use Illuminate\Http\Request; @@ -19,6 +20,7 @@ class UserAccountController extends Controller ) { $this->middleware(function (Request $request, Closure $next) { $this->preventGuestAccess(); + $this->preventAccessInDemoMode(); return $next($request); }); } @@ -35,6 +37,51 @@ class UserAccountController extends Controller ]); } + /** + * Show the profile form interface. + */ + public function showProfile() + { + return view('users.account.profile', [ + 'model' => user(), + 'category' => 'profile', + ]); + } + + /** + * Handle the submission of the user profile form. + */ + public function updateProfile(Request $request, ImageRepo $imageRepo) + { + $user = user(); + $validated = $this->validate($request, [ + 'name' => ['min:2', 'max:100'], + 'email' => ['min:2', 'email', 'unique:users,email,' . $user->id], + 'language' => ['string', 'max:15', 'alpha_dash'], + 'profile_image' => array_merge(['nullable'], $this->getImageValidationRules()), + ]); + + $this->userRepo->update($user, $validated, userCan('users-manage')); + + // Save profile image if in request + if ($request->hasFile('profile_image')) { + $imageUpload = $request->file('profile_image'); + $imageRepo->destroyImage($user->avatar); + $image = $imageRepo->saveNew($imageUpload, 'user', $user->id); + $user->image_id = $image->id; + $user->save(); + } + + // Delete the profile image if reset option is in request + if ($request->has('profile_image_reset')) { + $imageRepo->destroyImage($user->avatar); + $user->image_id = 0; + $user->save(); + } + + return redirect('/my-account/profile'); + } + /** * Show the user-specific interface shortcuts. */ diff --git a/lang/en/preferences.php b/lang/en/preferences.php index d112b9ebb..7774db570 100644 --- a/lang/en/preferences.php +++ b/lang/en/preferences.php @@ -35,5 +35,5 @@ return [ 'auth_change_password_success' => 'Password has been updated!', 'profile' => 'Profile Details', - 'profile_overview_desc' => ' Manage your user profile details including preferred language and authentication options.', + 'profile_overview_desc' => 'Manage your user profile details including preferred language and authentication options.', ]; diff --git a/resources/views/users/account/profile.blade.php b/resources/views/users/account/profile.blade.php new file mode 100644 index 000000000..4256df109 --- /dev/null +++ b/resources/views/users/account/profile.blade.php @@ -0,0 +1,107 @@ +@extends('users.account.layout') + +@section('main') + +

    +
    + {{ method_field('put') }} + {{ csrf_field() }} + +
    +

    {{ trans('preferences.profile') }}

    + +
    + +

    + Manage the details of your account that represent you to other users, in addition to + details that are used for communication and system personalisation. +

    + +
    + +
    +
    + +

    + Configure your display name which will be visible to other users in the system + within the activity you perform, and content you own. +

    +
    +
    + @include('form.text', ['name' => 'name']) +
    +
    + +
    +
    +
    + +

    + This email will be used for notifications and, depending on active system authentication, system access. +

    +
    +
    + @include('form.text', ['name' => 'email', 'disabled' => !userCan('users-manage')]) +
    +
    + @if(!userCan('users-manage')) +

    + Unfortunately you don't have permission to change your email address. + If you want to change this, you'd need to ask an administrator to change this for you. +

    + @endif +
    + +
    +
    + +

    + Select an image which will be used to represent yourself to others + in the system. Ideally this image should be square and about 256px in width and height. +

    +
    +
    + @include('form.image-picker', [ + 'resizeHeight' => '512', + 'resizeWidth' => '512', + 'showRemove' => false, + 'defaultImage' => url('/user_avatar.png'), + 'currentImage' => user()->getAvatar(80), + 'currentId' => user()->image_id, + 'name' => 'profile_image', + 'imageClass' => 'avatar large' + ]) +
    +
    + + @include('users.parts.language-option-row', ['value' => old('language') ?? user()->getLocale()->appLocale()]) + +
    + +
    + +
    + +
    +
    + + @if(userCan('users-manage')) +
    +
    +
    +

    Administrator Options

    +

    + Additional administrator-level options, like role options, can be found for your user account in the + "Settings > Users" area of the application. +

    +
    +
    + Open +
    +
    +
    + @endif +@stop diff --git a/routes/web.php b/routes/web.php index a7d3534bd..3f1bcc07e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -234,6 +234,8 @@ Route::middleware('auth')->group(function () { // User Account Route::get('/my-account', [UserControllers\UserAccountController::class, 'index']); + Route::get('/my-account/profile', [UserControllers\UserAccountController::class, 'showProfile']); + Route::put('/my-account/profile', [UserControllers\UserAccountController::class, 'updateProfile']); Route::get('/my-account/shortcuts', [UserControllers\UserAccountController::class, 'showShortcuts']); Route::put('/my-account/shortcuts', [UserControllers\UserAccountController::class, 'updateShortcuts']); Route::get('/my-account/notifications', [UserControllers\UserAccountController::class, 'showNotifications']); From 03c44b399240a0c542aee03ec974c66ffd661865 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 18 Oct 2023 17:53:58 +0100 Subject: [PATCH 04/12] My Account: Extracted/tweaked profile text, removed old index --- app/Api/ApiDocsController.php | 2 + .../Controllers/UserAccountController.php | 12 ++-- lang/en/common.php | 1 + lang/en/preferences.php | 9 ++- resources/views/users/account/index.blade.php | 62 ------------------- .../views/users/account/profile.blade.php | 35 +++-------- routes/web.php | 2 +- 7 files changed, 26 insertions(+), 97 deletions(-) delete mode 100644 resources/views/users/account/index.blade.php diff --git a/app/Api/ApiDocsController.php b/app/Api/ApiDocsController.php index 382ec15eb..d88dba3bc 100644 --- a/app/Api/ApiDocsController.php +++ b/app/Api/ApiDocsController.php @@ -31,6 +31,8 @@ class ApiDocsController extends ApiController /** * Redirect to the API docs page. + * Required as a controller method, instead of the Route::redirect helper, + * to ensure the URL is generated correctly. */ public function redirect() { diff --git a/app/Users/Controllers/UserAccountController.php b/app/Users/Controllers/UserAccountController.php index 83e942b04..bdd923d6d 100644 --- a/app/Users/Controllers/UserAccountController.php +++ b/app/Users/Controllers/UserAccountController.php @@ -26,15 +26,13 @@ class UserAccountController extends Controller } /** - * Show the overview for user preferences. + * Redirect the root my-account path to the main/first category. + * Required as a controller method, instead of the Route::redirect helper, + * to ensure the URL is generated correctly. */ - public function index() + public function redirect() { - $mfaMethods = user()->mfaValues->groupBy('method'); - - return view('users.account.index', [ - 'mfaMethods' => $mfaMethods, - ]); + return redirect('/my-account/profile'); } /** diff --git a/lang/en/common.php b/lang/en/common.php index 47b74d5b6..27037babe 100644 --- a/lang/en/common.php +++ b/lang/en/common.php @@ -52,6 +52,7 @@ return [ 'filter_clear' => 'Clear Filter', 'download' => 'Download', 'open_in_tab' => 'Open in Tab', + 'open' => 'Open', // Sort Options 'sort_options' => 'Sort Options', diff --git a/lang/en/preferences.php b/lang/en/preferences.php index 7774db570..042612662 100644 --- a/lang/en/preferences.php +++ b/lang/en/preferences.php @@ -35,5 +35,12 @@ return [ 'auth_change_password_success' => 'Password has been updated!', 'profile' => 'Profile Details', - 'profile_overview_desc' => 'Manage your user profile details including preferred language and authentication options.', + 'profile_desc' => 'Manage the details of your account which represents you to other users, in addition to details that are used for communication and system personalisation.', + 'profile_view_public' => 'View Public Profile', + 'profile_name_desc' => 'Configure your display name which will be visible to other users in the system through the activity you perform, and content you own.', + 'profile_email_desc' => 'This email will be used for notifications and, depending on active system authentication, system access.', + 'profile_email_no_permission' => 'Unfortunately you don\'t have permission to change your email address. If you want to change this, you\'d need to ask an administrator to change this for you.', + 'profile_avatar_desc' => 'Select an image which will be used to represent yourself to others in the system. Ideally this image should be square and about 256px in width and height.', + 'profile_admin_options' => 'Administrator Options', + 'profile_admin_options_desc' => 'Additional administrator-level options, like those to manage role assignments, can be found for your user account in the "Settings > Users" area of the application.', ]; diff --git a/resources/views/users/account/index.blade.php b/resources/views/users/account/index.blade.php deleted file mode 100644 index 11a5bc9c9..000000000 --- a/resources/views/users/account/index.blade.php +++ /dev/null @@ -1,62 +0,0 @@ -@extends('layouts.simple') - -@section('body') -
    - -
    -
    -

    {{ trans('preferences.shortcuts_interface') }}

    -

    {{ trans('preferences.shortcuts_overview_desc') }}

    -
    - -
    - - @if(!user()->isGuest() && userCan('receive-notifications')) -
    -
    -

    {{ trans('preferences.notifications') }}

    -

    {{ trans('preferences.notifications_desc') }}

    -
    - -
    - @endif - - @if(!user()->isGuest()) -
    -
    -

    {{ trans('settings.users_edit_profile') }}

    -

    {{ trans('preferences.profile_overview_desc') }}

    -
    - -
    - @endif - - @if(!user()->isGuest()) -
    -
    -

    {{ trans('settings.users_mfa') }}

    -

    {{ trans('settings.users_mfa_desc') }}

    -

    - @if ($mfaMethods->count() > 0) - @icon('check-circle') - @else - @icon('cancel') - @endif - {{ trans_choice('settings.users_mfa_x_methods', $mfaMethods->count()) }} -

    -
    - -
    - @endif - -
    -@stop diff --git a/resources/views/users/account/profile.blade.php b/resources/views/users/account/profile.blade.php index 4256df109..785d562e4 100644 --- a/resources/views/users/account/profile.blade.php +++ b/resources/views/users/account/profile.blade.php @@ -10,24 +10,18 @@ -

    - Manage the details of your account that represent you to other users, in addition to - details that are used for communication and system personalisation. -

    +

    {{ trans('preferences.profile_desc') }}

    -

    - Configure your display name which will be visible to other users in the system - within the activity you perform, and content you own. -

    +

    {{ trans('preferences.profile_name_desc') }}

    @include('form.text', ['name' => 'name']) @@ -38,19 +32,14 @@
    -

    - This email will be used for notifications and, depending on active system authentication, system access. -

    +

    {{ trans('preferences.profile_email_desc') }}

    @include('form.text', ['name' => 'email', 'disabled' => !userCan('users-manage')])
    @if(!userCan('users-manage')) -

    - Unfortunately you don't have permission to change your email address. - If you want to change this, you'd need to ask an administrator to change this for you. -

    +

    {{ trans('preferences.profile_email_no_permission') }}

    @endif
    @@ -58,10 +47,7 @@
    -

    - Select an image which will be used to represent yourself to others - in the system. Ideally this image should be square and about 256px in width and height. -

    +

    {{ trans('preferences.profile_avatar_desc') }}

    @include('form.image-picker', [ @@ -92,14 +78,11 @@
    -

    Administrator Options

    -

    - Additional administrator-level options, like role options, can be found for your user account in the - "Settings > Users" area of the application. -

    +

    {{ trans('preferences.profile_admin_options') }}

    +

    {{ trans('preferences.profile_admin_options_desc') }}

    diff --git a/routes/web.php b/routes/web.php index 3f1bcc07e..16c3c3d6a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -233,7 +233,7 @@ Route::middleware('auth')->group(function () { Route::delete('/settings/users/{id}', [UserControllers\UserController::class, 'destroy']); // User Account - Route::get('/my-account', [UserControllers\UserAccountController::class, 'index']); + Route::get('/my-account', [UserControllers\UserAccountController::class, 'redirect']); Route::get('/my-account/profile', [UserControllers\UserAccountController::class, 'showProfile']); Route::put('/my-account/profile', [UserControllers\UserAccountController::class, 'updateProfile']); Route::get('/my-account/shortcuts', [UserControllers\UserAccountController::class, 'showShortcuts']); From e4ea73ee25d79558ba2f0546e11197e01483b62f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 18 Oct 2023 17:57:14 +0100 Subject: [PATCH 05/12] My Account: Cleaned-up/reorganised user header dropdown --- .../layouts/parts/header-user-menu.blade.php | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/resources/views/layouts/parts/header-user-menu.blade.php b/resources/views/layouts/parts/header-user-menu.blade.php index 694b7b87a..0440e43d0 100644 --- a/resources/views/layouts/parts/header-user-menu.blade.php +++ b/resources/views/layouts/parts/header-user-menu.blade.php @@ -18,11 +18,16 @@
  • - - @icon('edit') -
    {{ trans('common.edit_profile') }}
    +
    + @icon('user-preferences') +
    {{ trans('preferences.my_account') }}
  • +

  • +
  • + @include('common.dark-mode-toggle', ['classes' => 'icon-item']) +
  • +

  • @@ -33,15 +38,5 @@
  • -

  • -
  • - - @icon('user-preferences') -
    {{ trans('preferences.my_account') }}
    -
    -
  • -
  • - @include('common.dark-mode-toggle', ['classes' => 'icon-item']) -
  • \ No newline at end of file From cf72e48d2a0e2fea8bbbbb777309af3b836010aa Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 19 Oct 2023 10:20:04 +0100 Subject: [PATCH 06/12] User form: Always show external auth field, update access control Updated old user management routes to only be accessible with permission to manage users, so also removed old content controls checking for that permission. --- app/Users/Controllers/UserController.php | 18 ++++------ lang/en/settings.php | 2 +- resources/views/users/edit.blade.php | 6 ++-- resources/views/users/parts/form.blade.php | 39 ++++++++++------------ 4 files changed, 27 insertions(+), 38 deletions(-) diff --git a/app/Users/Controllers/UserController.php b/app/Users/Controllers/UserController.php index 0cd48948f..507c7cf06 100644 --- a/app/Users/Controllers/UserController.php +++ b/app/Users/Controllers/UserController.php @@ -103,8 +103,7 @@ class UserController extends Controller */ public function edit(int $id, SocialAuthService $socialAuthService) { - $this->preventGuestAccess(); - $this->checkPermissionOrCurrentUser('users-manage', $id); + $this->checkPermission('users-manage'); $user = $this->userRepo->getById($id); $user->load(['apiTokens', 'mfaValues']); @@ -134,8 +133,7 @@ class UserController extends Controller public function update(Request $request, int $id) { $this->preventAccessInDemoMode(); - $this->preventGuestAccess(); - $this->checkPermissionOrCurrentUser('users-manage', $id); + $this->checkPermission('users-manage'); $validated = $this->validate($request, [ 'name' => ['min:2', 'max:100'], @@ -150,7 +148,7 @@ class UserController extends Controller ]); $user = $this->userRepo->getById($id); - $this->userRepo->update($user, $validated, userCan('users-manage')); + $this->userRepo->update($user, $validated, true); // Save profile image if in request if ($request->hasFile('profile_image')) { @@ -168,9 +166,7 @@ class UserController extends Controller $user->save(); } - $redirectUrl = userCan('users-manage') ? '/settings/users' : "/settings/users/{$user->id}"; - - return redirect($redirectUrl); + return redirect('/settings/users'); } /** @@ -178,8 +174,7 @@ class UserController extends Controller */ public function delete(int $id) { - $this->preventGuestAccess(); - $this->checkPermissionOrCurrentUser('users-manage', $id); + $this->checkPermission('users-manage'); $user = $this->userRepo->getById($id); $this->setPageTitle(trans('settings.users_delete_named', ['userName' => $user->name])); @@ -195,8 +190,7 @@ class UserController extends Controller public function destroy(Request $request, int $id) { $this->preventAccessInDemoMode(); - $this->preventGuestAccess(); - $this->checkPermissionOrCurrentUser('users-manage', $id); + $this->checkPermission('users-manage'); $user = $this->userRepo->getById($id); $newOwnerId = intval($request->get('new_owner_id')) ?: null; diff --git a/lang/en/settings.php b/lang/en/settings.php index 579c4b5c8..dfd0f7841 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -193,7 +193,7 @@ return [ 'users_send_invite_text' => 'You can choose to send this user an invitation email which allows them to set their own password otherwise you can set their password yourself.', 'users_send_invite_option' => 'Send user invite email', 'users_external_auth_id' => 'External Authentication ID', - 'users_external_auth_id_desc' => 'This is the ID used to match this user when communicating with your external authentication system.', + 'users_external_auth_id_desc' => 'When an external authentication system is in use (such as SAML2, OIDC or LDAP) this is the ID which links this BookStack user to the authentication system account. You can ignore this field if using the default email-based authentication.', 'users_password_warning' => 'Only fill the below if you would like to change the password for this user.', 'users_system_public' => 'This user represents any guest users that visit your instance. It cannot be used to log in but is assigned automatically.', 'users_delete' => 'Delete User', diff --git a/resources/views/users/edit.blade.php b/resources/views/users/edit.blade.php index e6b477a12..1254a1330 100644 --- a/resources/views/users/edit.blade.php +++ b/resources/views/users/edit.blade.php @@ -38,7 +38,7 @@ @stop diff --git a/resources/views/users/parts/form.blade.php b/resources/views/users/parts/form.blade.php index d9f958837..bf1eb08a7 100644 --- a/resources/views/users/parts/form.blade.php +++ b/resources/views/users/parts/form.blade.php @@ -11,7 +11,7 @@ @if($authMethod === 'ldap' || $authMethod === 'system')

    {{ trans('settings.users_details_desc_no_email') }}

    @endif -
    +
    @include('form.text', ['name' => 'name']) @@ -23,29 +23,26 @@ @endif
    +
    +
    + +
    +

    {{ trans('settings.users_external_auth_id_desc') }}

    + @include('form.text', ['name' => 'external_auth_id']) +
    +
    +
    -@if(in_array($authMethod, ['ldap', 'saml2', 'oidc']) && userCan('users-manage')) -
    -
    - -

    {{ trans('settings.users_external_auth_id_desc') }}

    -
    -
    - @include('form.text', ['name' => 'external_auth_id']) -
    +
    + +

    {{ trans('settings.users_role_desc') }}

    +
    + @include('form.role-checkboxes', ['name' => 'roles', 'roles' => $roles])
    -@endif - -@if(userCan('users-manage')) -
    - -

    {{ trans('settings.users_role_desc') }}

    -
    - @include('form.role-checkboxes', ['name' => 'roles', 'roles' => $roles]) -
    -
    -@endif +
    @if($authMethod === 'standard')
    From f9422dff18ff9c36d861537b05676807c437a14a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 19 Oct 2023 10:48:27 +0100 Subject: [PATCH 07/12] My Account: Added self-delete flow --- .../Controllers/UserAccountController.php | 27 ++++++++++++ lang/en/preferences.php | 5 +++ .../views/users/account/delete.blade.php | 43 +++++++++++++++++++ .../views/users/account/profile.blade.php | 1 + resources/views/users/delete.blade.php | 24 +++++------ routes/web.php | 2 + 6 files changed, 89 insertions(+), 13 deletions(-) create mode 100644 resources/views/users/account/delete.blade.php diff --git a/app/Users/Controllers/UserAccountController.php b/app/Users/Controllers/UserAccountController.php index bdd923d6d..2ff58ffac 100644 --- a/app/Users/Controllers/UserAccountController.php +++ b/app/Users/Controllers/UserAccountController.php @@ -191,4 +191,31 @@ class UserAccountController extends Controller return redirect('/my-account/auth'); } + + /** + * Show the user self-delete page. + */ + public function delete() + { + $this->setPageTitle(trans('preferences.delete_my_account')); + + return view('users.account.delete', [ + 'category' => 'profile', + ]); + } + + /** + * Remove the current user from the system. + */ + public function destroy(Request $request) + { + $this->preventAccessInDemoMode(); + + $requestNewOwnerId = intval($request->get('new_owner_id')) ?: null; + $newOwnerId = userCan('users-manage') ? $requestNewOwnerId : null; + + $this->userRepo->destroy(user(), $newOwnerId); + + return redirect('/'); + } } diff --git a/lang/en/preferences.php b/lang/en/preferences.php index 042612662..2b88f9671 100644 --- a/lang/en/preferences.php +++ b/lang/en/preferences.php @@ -43,4 +43,9 @@ return [ 'profile_avatar_desc' => 'Select an image which will be used to represent yourself to others in the system. Ideally this image should be square and about 256px in width and height.', 'profile_admin_options' => 'Administrator Options', 'profile_admin_options_desc' => 'Additional administrator-level options, like those to manage role assignments, can be found for your user account in the "Settings > Users" area of the application.', + + 'delete_account' => 'Delete Account', + 'delete_my_account' => 'Delete My Account', + 'delete_my_account_desc' => 'This will fully delete your user account from the system. You will not be able to recover this account or revert this action. Content you\'ve created, such as created pages and uploaded images, will remain.', + 'delete_my_account_warning' => 'Are you sure you want to delete your account?', ]; diff --git a/resources/views/users/account/delete.blade.php b/resources/views/users/account/delete.blade.php new file mode 100644 index 000000000..75698d919 --- /dev/null +++ b/resources/views/users/account/delete.blade.php @@ -0,0 +1,43 @@ +@extends('users.account.layout') + +@section('main') + + + +@stop diff --git a/resources/views/users/account/profile.blade.php b/resources/views/users/account/profile.blade.php index 785d562e4..617c09723 100644 --- a/resources/views/users/account/profile.blade.php +++ b/resources/views/users/account/profile.blade.php @@ -68,6 +68,7 @@
    + {{ trans('preferences.delete_account') }}
    diff --git a/resources/views/users/delete.blade.php b/resources/views/users/delete.blade.php index b2f08b641..c927ed243 100644 --- a/resources/views/users/delete.blade.php +++ b/resources/views/users/delete.blade.php @@ -6,33 +6,31 @@ @include('settings.parts.navbar', ['selected' => 'users'])
    id}") }}" method="POST"> - {!! csrf_field() !!} + {{ csrf_field() }} + {{ method_field('delete') }}

    {{ trans('settings.users_delete') }}

    {{ trans('settings.users_delete_warning', ['userName' => $user->name]) }}

    - @if(userCan('users-manage')) -
    +
    -
    -
    - -

    {{ trans('settings.users_migrate_ownership_desc') }}

    -
    -
    - @include('form.user-select', ['name' => 'new_owner_id', 'user' => null]) -
    +
    +
    + +

    {{ trans('settings.users_migrate_ownership_desc') }}

    - @endif +
    + @include('form.user-select', ['name' => 'new_owner_id', 'user' => null]) +
    +

    {{ trans('settings.users_delete_confirm') }}

    diff --git a/routes/web.php b/routes/web.php index 16c3c3d6a..69ce5167c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -242,6 +242,8 @@ Route::middleware('auth')->group(function () { Route::put('/my-account/notifications', [UserControllers\UserAccountController::class, 'updateNotifications']); Route::get('/my-account/auth', [UserControllers\UserAccountController::class, 'showAuth']); Route::put('/my-account/auth/password', [UserControllers\UserAccountController::class, 'updatePassword']); + Route::get('/my-account/delete', [UserControllers\UserAccountController::class, 'delete']); + Route::delete('/my-account', [UserControllers\UserAccountController::class, 'destroy']); Route::patch('/preferences/change-view/{type}', [UserControllers\UserPreferencesController::class, 'changeView']); Route::patch('/preferences/change-sort/{type}', [UserControllers\UserPreferencesController::class, 'changeSort']); Route::patch('/preferences/change-expansion/{type}', [UserControllers\UserPreferencesController::class, 'changeExpansion']); From 12946414b05930efca3f3e97970a25b94d16bf0c Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 19 Oct 2023 11:31:45 +0100 Subject: [PATCH 08/12] API Tokens: Updated interfaces to return to correct location Since management of API tokens can be accessed via two routes, this adds tracking and handling to reutrn the user to the correct place. --- app/Api/ApiToken.php | 8 ++++ app/Api/UserApiTokenController.php | 42 ++++++++++++++++--- resources/views/users/account/auth.blade.php | 2 +- .../views/users/api-tokens/create.blade.php | 6 +-- .../views/users/api-tokens/delete.blade.php | 8 ++-- .../views/users/api-tokens/edit.blade.php | 10 ++--- .../users/api-tokens/parts/list.blade.php | 6 +-- resources/views/users/edit.blade.php | 2 +- routes/web.php | 12 +++--- 9 files changed, 67 insertions(+), 29 deletions(-) diff --git a/app/Api/ApiToken.php b/app/Api/ApiToken.php index 5c2d591e4..ca89c813e 100644 --- a/app/Api/ApiToken.php +++ b/app/Api/ApiToken.php @@ -52,4 +52,12 @@ class ApiToken extends Model implements Loggable { return "({$this->id}) {$this->name}; User: {$this->user->logDescriptor()}"; } + + /** + * Get the URL for managing this token. + */ + public function getUrl(string $path = ''): string + { + return url("/api-tokens/{$this->user_id}/{$this->id}/" . trim($path, '/')); + } } diff --git a/app/Api/UserApiTokenController.php b/app/Api/UserApiTokenController.php index 8357420ee..7455be4ff 100644 --- a/app/Api/UserApiTokenController.php +++ b/app/Api/UserApiTokenController.php @@ -14,16 +14,17 @@ class UserApiTokenController extends Controller /** * Show the form to create a new API token. */ - public function create(int $userId) + public function create(Request $request, int $userId) { - // Ensure user is has access-api permission and is the current user or has permission to manage the current user. $this->checkPermission('access-api'); $this->checkPermissionOrCurrentUser('users-manage', $userId); + $this->updateContext($request); $user = User::query()->findOrFail($userId); return view('users.api-tokens.create', [ 'user' => $user, + 'back' => $this->getRedirectPath($user), ]); } @@ -60,14 +61,16 @@ class UserApiTokenController extends Controller session()->flash('api-token-secret:' . $token->id, $secret); $this->logActivity(ActivityType::API_TOKEN_CREATE, $token); - return redirect($user->getEditUrl('/api-tokens/' . $token->id)); + return redirect($token->getUrl()); } /** * Show the details for a user API token, with access to edit. */ - public function edit(int $userId, int $tokenId) + public function edit(Request $request, int $userId, int $tokenId) { + $this->updateContext($request); + [$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId); $secret = session()->pull('api-token-secret:' . $token->id, null); @@ -76,6 +79,7 @@ class UserApiTokenController extends Controller 'token' => $token, 'model' => $token, 'secret' => $secret, + 'back' => $this->getRedirectPath($user), ]); } @@ -97,7 +101,7 @@ class UserApiTokenController extends Controller $this->logActivity(ActivityType::API_TOKEN_UPDATE, $token); - return redirect($user->getEditUrl('/api-tokens/' . $token->id)); + return redirect($token->getUrl()); } /** @@ -123,7 +127,7 @@ class UserApiTokenController extends Controller $this->logActivity(ActivityType::API_TOKEN_DELETE, $token); - return redirect($user->getEditUrl('#api_tokens')); + return redirect($this->getRedirectPath($user)); } /** @@ -142,4 +146,30 @@ class UserApiTokenController extends Controller return [$user, $token]; } + + /** + * Update the context for where the user is coming from to manage API tokens. + * (Track of location for correct return redirects) + */ + protected function updateContext(Request $request): void + { + $context = $request->query('context'); + if ($context) { + session()->put('api-token-context', $context); + } + } + + /** + * Get the redirect path for the current api token editing session. + * Attempts to recall the context of where the user is editing from. + */ + protected function getRedirectPath(User $relatedUser): string + { + $context = session()->get('api-token-context'); + if ($context === 'settings') { + return $relatedUser->getEditUrl('#api_tokens'); + } + + return url('/my-account/auth#api_tokens'); + } } diff --git a/resources/views/users/account/auth.blade.php b/resources/views/users/account/auth.blade.php index 3503978cf..d6f85093b 100644 --- a/resources/views/users/account/auth.blade.php +++ b/resources/views/users/account/auth.blade.php @@ -82,6 +82,6 @@ @endif @if(userCan('access-api')) - @include('users.api-tokens.parts.list', ['user' => user()]) + @include('users.api-tokens.parts.list', ['user' => user(), 'context' => 'my-account']) @endif @stop diff --git a/resources/views/users/api-tokens/create.blade.php b/resources/views/users/api-tokens/create.blade.php index 9cf772082..8250c5ae8 100644 --- a/resources/views/users/api-tokens/create.blade.php +++ b/resources/views/users/api-tokens/create.blade.php @@ -7,8 +7,8 @@

    {{ trans('settings.user_api_token_create') }}

    - - {!! csrf_field() !!} + + {{ csrf_field() }}
    @include('users.api-tokens.parts.form') @@ -21,7 +21,7 @@
    diff --git a/resources/views/users/api-tokens/delete.blade.php b/resources/views/users/api-tokens/delete.blade.php index 45f0e2fa0..2b9a29e6a 100644 --- a/resources/views/users/api-tokens/delete.blade.php +++ b/resources/views/users/api-tokens/delete.blade.php @@ -11,11 +11,11 @@

    {{ trans('settings.user_api_token_delete_confirm') }}

    - - {!! csrf_field() !!} - {!! method_field('delete') !!} + + {{ csrf_field() }} + {{ method_field('delete') }} - {{ trans('common.cancel') }} + {{ trans('common.cancel') }}
    diff --git a/resources/views/users/api-tokens/edit.blade.php b/resources/views/users/api-tokens/edit.blade.php index 61c1ac2a6..aa3e49ded 100644 --- a/resources/views/users/api-tokens/edit.blade.php +++ b/resources/views/users/api-tokens/edit.blade.php @@ -7,9 +7,9 @@

    {{ trans('settings.user_api_token') }}

    -
    - {!! method_field('put') !!} - {!! csrf_field() !!} + + {{ method_field('put') }} + {{ csrf_field() }}
    @@ -52,8 +52,8 @@
    diff --git a/resources/views/users/api-tokens/parts/list.blade.php b/resources/views/users/api-tokens/parts/list.blade.php index 3081682a4..70aaa58f3 100644 --- a/resources/views/users/api-tokens/parts/list.blade.php +++ b/resources/views/users/api-tokens/parts/list.blade.php @@ -4,7 +4,7 @@
    @@ -14,7 +14,7 @@ @foreach($user->apiTokens as $token)
    - {{ $token->name }}
    + {{ $token->name }}
    {{ $token->token_id }}
    @@ -23,7 +23,7 @@ {{ $token->expires_at->format('Y-m-d') ?? '' }}
    diff --git a/resources/views/users/edit.blade.php b/resources/views/users/edit.blade.php index 1254a1330..076b28c74 100644 --- a/resources/views/users/edit.blade.php +++ b/resources/views/users/edit.blade.php @@ -100,7 +100,7 @@ @endif - @include('users.api-tokens.parts.list', ['user' => $user]) + @include('users.api-tokens.parts.list', ['user' => $user, 'context' => 'settings'])
    @stop diff --git a/routes/web.php b/routes/web.php index 69ce5167c..c2f4891b8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -251,12 +251,12 @@ Route::middleware('auth')->group(function () { Route::patch('/preferences/update-code-language-favourite', [UserControllers\UserPreferencesController::class, 'updateCodeLanguageFavourite']); // User API Tokens - Route::get('/settings/users/{userId}/create-api-token', [UserApiTokenController::class, 'create']); - Route::post('/settings/users/{userId}/create-api-token', [UserApiTokenController::class, 'store']); - Route::get('/settings/users/{userId}/api-tokens/{tokenId}', [UserApiTokenController::class, 'edit']); - Route::put('/settings/users/{userId}/api-tokens/{tokenId}', [UserApiTokenController::class, 'update']); - Route::get('/settings/users/{userId}/api-tokens/{tokenId}/delete', [UserApiTokenController::class, 'delete']); - Route::delete('/settings/users/{userId}/api-tokens/{tokenId}', [UserApiTokenController::class, 'destroy']); + Route::get('/api-tokens/{userId}/create', [UserApiTokenController::class, 'create']); + Route::post('/api-tokens/{userId}/create', [UserApiTokenController::class, 'store']); + Route::get('/api-tokens/{userId}/{tokenId}', [UserApiTokenController::class, 'edit']); + Route::put('/api-tokens/{userId}/{tokenId}', [UserApiTokenController::class, 'update']); + Route::get('/api-tokens/{userId}/{tokenId}/delete', [UserApiTokenController::class, 'delete']); + Route::delete('/api-tokens/{userId}/{tokenId}', [UserApiTokenController::class, 'destroy']); // Roles Route::get('/settings/roles', [UserControllers\RoleController::class, 'index']); From fabc854390abe2ac49d16ada34ec2d8000972cf5 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 19 Oct 2023 14:18:42 +0100 Subject: [PATCH 09/12] My Account: Updated and started adding to tests - Updated existing tests now affected by my-account changes. - Updated some existing tests to more accuractly check the scenario. - Updated some code styling in SocialController. - Fixed redirects for social account flows to fit my-account. - Added test for social account attaching. - Added test for api token redirect handling. --- app/Access/Controllers/SocialController.php | 18 +- app/Access/SocialAuthService.php | 6 +- app/Api/UserApiTokenController.php | 2 +- lang/en/settings.php | 2 + .../views/users/account/layout.blade.php | 4 +- resources/views/users/edit.blade.php | 4 +- routes/web.php | 2 + tests/Actions/WebhookCallTest.php | 8 +- tests/Auth/SocialAuthTest.php | 43 ++++- tests/Permissions/RolePermissionsTest.php | 15 +- tests/Uploads/ImageTest.php | 4 +- tests/User/UserApiTokenTest.php | 86 ++++++--- tests/User/UserMyAccountTest.php | 174 ++++++++++++++++++ tests/User/UserPreferencesTest.php | 161 ---------------- 14 files changed, 302 insertions(+), 227 deletions(-) create mode 100644 tests/User/UserMyAccountTest.php diff --git a/app/Access/Controllers/SocialController.php b/app/Access/Controllers/SocialController.php index 3df895dd8..ff6d5c2dd 100644 --- a/app/Access/Controllers/SocialController.php +++ b/app/Access/Controllers/SocialController.php @@ -16,22 +16,12 @@ use Laravel\Socialite\Contracts\User as SocialUser; class SocialController extends Controller { - protected SocialAuthService $socialAuthService; - protected RegistrationService $registrationService; - protected LoginService $loginService; - - /** - * SocialController constructor. - */ public function __construct( - SocialAuthService $socialAuthService, - RegistrationService $registrationService, - LoginService $loginService + protected SocialAuthService $socialAuthService, + protected RegistrationService $registrationService, + protected LoginService $loginService, ) { $this->middleware('guest')->only(['register']); - $this->socialAuthService = $socialAuthService; - $this->registrationService = $registrationService; - $this->loginService = $loginService; } /** @@ -112,7 +102,7 @@ class SocialController extends Controller $this->socialAuthService->detachSocialAccount($socialDriver); session()->flash('success', trans('settings.users_social_disconnected', ['socialAccount' => Str::title($socialDriver)])); - return redirect(user()->getEditUrl()); + return redirect('/my-account/auth#social-accounts'); } /** diff --git a/app/Access/SocialAuthService.php b/app/Access/SocialAuthService.php index fe9195430..f0e0413f0 100644 --- a/app/Access/SocialAuthService.php +++ b/app/Access/SocialAuthService.php @@ -154,21 +154,21 @@ class SocialAuthService $currentUser->socialAccounts()->save($account); session()->flash('success', trans('settings.users_social_connected', ['socialAccount' => $titleCaseDriver])); - return redirect($currentUser->getEditUrl()); + return redirect('/my-account/auth#social_accounts'); } // When a user is logged in and the social account exists and is already linked to the current user. if ($isLoggedIn && $socialAccount !== null && $socialAccount->user->id === $currentUser->id) { session()->flash('error', trans('errors.social_account_existing', ['socialAccount' => $titleCaseDriver])); - return redirect($currentUser->getEditUrl()); + return redirect('/my-account/auth#social_accounts'); } // When a user is logged in, A social account exists but the users do not match. if ($isLoggedIn && $socialAccount !== null && $socialAccount->user->id != $currentUser->id) { session()->flash('error', trans('errors.social_account_already_used_existing', ['socialAccount' => $titleCaseDriver])); - return redirect($currentUser->getEditUrl()); + return redirect('/my-account/auth#social_accounts'); } // Otherwise let the user know this social account is not used by anyone. diff --git a/app/Api/UserApiTokenController.php b/app/Api/UserApiTokenController.php index 7455be4ff..b77e39089 100644 --- a/app/Api/UserApiTokenController.php +++ b/app/Api/UserApiTokenController.php @@ -166,7 +166,7 @@ class UserApiTokenController extends Controller protected function getRedirectPath(User $relatedUser): string { $context = session()->get('api-token-context'); - if ($context === 'settings') { + if ($context === 'settings' || user()->id !== $relatedUser->id) { return $relatedUser->getEditUrl('#api_tokens'); } diff --git a/lang/en/settings.php b/lang/en/settings.php index dfd0f7841..9e49c7ca7 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -214,6 +214,8 @@ return [ 'users_social_accounts_info' => 'Here you can connect your other accounts for quicker and easier login. Disconnecting an account here does not revoke previously authorized access. Revoke access from your profile settings on the connected social account.', 'users_social_connect' => 'Connect Account', 'users_social_disconnect' => 'Disconnect Account', + 'users_social_status_connected' => 'Connected', + 'users_social_status_disconnected' => 'Disconnected', 'users_social_connected' => ':socialAccount account was successfully attached to your profile.', 'users_social_disconnected' => ':socialAccount account was successfully disconnected from your profile.', 'users_api_tokens' => 'API Tokens', diff --git a/resources/views/users/account/layout.blade.php b/resources/views/users/account/layout.blade.php index ff5ad3622..f54a51c5a 100644 --- a/resources/views/users/account/layout.blade.php +++ b/resources/views/users/account/layout.blade.php @@ -12,7 +12,9 @@ @icon('user') {{ trans('preferences.profile') }} @icon('security') {{ trans('preferences.auth') }} @icon('shortcuts') {{ trans('preferences.shortcuts_interface') }} - @icon('notifications') {{ trans('preferences.notifications') }} + @if(userCan('receive-notifications')) + @icon('notifications') {{ trans('preferences.notifications') }} + @endif
    diff --git a/resources/views/users/edit.blade.php b/resources/views/users/edit.blade.php index 076b28c74..2b736d81e 100644 --- a/resources/views/users/edit.blade.php +++ b/resources/views/users/edit.blade.php @@ -89,9 +89,9 @@
    @icon('auth/'. $driver, ['style' => 'width: 56px;height: 56px;'])

    {{ $driverName }}

    @if($user->hasSocialAccount($driver)) -

    Connected

    +

    {{ trans('settings.users_social_status_connected') }}

    @else -

    Disconnected

    +

    {{ trans('settings.users_social_status_disconnected') }}

    @endif @endforeach diff --git a/routes/web.php b/routes/web.php index c2f4891b8..c86509c68 100644 --- a/routes/web.php +++ b/routes/web.php @@ -244,6 +244,8 @@ Route::middleware('auth')->group(function () { Route::put('/my-account/auth/password', [UserControllers\UserAccountController::class, 'updatePassword']); Route::get('/my-account/delete', [UserControllers\UserAccountController::class, 'delete']); Route::delete('/my-account', [UserControllers\UserAccountController::class, 'destroy']); + + // User Preference Endpoints Route::patch('/preferences/change-view/{type}', [UserControllers\UserPreferencesController::class, 'changeView']); Route::patch('/preferences/change-sort/{type}', [UserControllers\UserPreferencesController::class, 'changeSort']); Route::patch('/preferences/change-expansion/{type}', [UserControllers\UserPreferencesController::class, 'changeExpansion']); diff --git a/tests/Actions/WebhookCallTest.php b/tests/Actions/WebhookCallTest.php index 81bd7e7e8..16986ba2e 100644 --- a/tests/Actions/WebhookCallTest.php +++ b/tests/Actions/WebhookCallTest.php @@ -51,7 +51,7 @@ class WebhookCallTest extends TestCase { // This test must not fake the queue/bus since this covers an issue // around handling and serialization of items now deleted from the database. - $this->newWebhook(['active' => true, 'endpoint' => 'https://wh.example.com'], ['all']); + $webhook = $this->newWebhook(['active' => true, 'endpoint' => 'https://wh.example.com'], ['all']); $this->mockHttpClient([new Response(500)]); $user = $this->users->newUser(); @@ -61,8 +61,10 @@ class WebhookCallTest extends TestCase /** @var ApiToken $apiToken */ $editor = $this->users->editor(); $apiToken = ApiToken::factory()->create(['user_id' => $editor]); - $resp = $this->delete($editor->getEditUrl('/api-tokens/' . $apiToken->id)); - $resp->assertRedirect($editor->getEditUrl('#api_tokens')); + $this->delete($apiToken->getUrl())->assertRedirect(); + + $webhook->refresh(); + $this->assertEquals('Response status from endpoint was 500', $webhook->last_error); } public function test_failed_webhook_call_logs_error() diff --git a/tests/Auth/SocialAuthTest.php b/tests/Auth/SocialAuthTest.php index 5b7071a07..89b8fd167 100644 --- a/tests/Auth/SocialAuthTest.php +++ b/tests/Auth/SocialAuthTest.php @@ -18,7 +18,7 @@ class SocialAuthTest extends TestCase $user = User::factory()->make(); $this->setSettings(['registration-enabled' => 'true']); - config(['GOOGLE_APP_ID' => 'abc123', 'GOOGLE_APP_SECRET' => '123abc', 'APP_URL' => 'http://localhost']); + config(['GOOGLE_APP_ID' => 'abc123', 'GOOGLE_APP_SECRET' => '123abc']); $mockSocialite = $this->mock(Factory::class); $mockSocialDriver = Mockery::mock(Provider::class); @@ -45,7 +45,6 @@ class SocialAuthTest extends TestCase config([ 'GOOGLE_APP_ID' => 'abc123', 'GOOGLE_APP_SECRET' => '123abc', 'GITHUB_APP_ID' => 'abc123', 'GITHUB_APP_SECRET' => '123abc', - 'APP_URL' => 'http://localhost', ]); $mockSocialite = $this->mock(Factory::class); @@ -86,12 +85,41 @@ class SocialAuthTest extends TestCase $this->assertActivityExists(ActivityType::AUTH_LOGIN, null, 'github; (' . $this->users->admin()->id . ') ' . $this->users->admin()->name); } + public function test_social_account_attach() + { + config([ + 'GOOGLE_APP_ID' => 'abc123', 'GOOGLE_APP_SECRET' => '123abc', + ]); + $editor = $this->users->editor(); + + $mockSocialite = $this->mock(Factory::class); + $mockSocialDriver = Mockery::mock(Provider::class); + $mockSocialUser = Mockery::mock(\Laravel\Socialite\Contracts\User::class); + + $mockSocialUser->shouldReceive('getId')->twice()->andReturn('logintest123'); + $mockSocialUser->shouldReceive('getAvatar')->andReturn(null); + + $mockSocialite->shouldReceive('driver')->twice()->with('google')->andReturn($mockSocialDriver); + $mockSocialDriver->shouldReceive('redirect')->once()->andReturn(redirect('/login/service/google/callback')); + $mockSocialDriver->shouldReceive('user')->once()->andReturn($mockSocialUser); + + // Test login routes + $resp = $this->actingAs($editor)->followingRedirects()->get('/login/service/google'); + $resp->assertSee('Access & Security'); + + // Test social callback with matching social account + $this->assertDatabaseHas('social_accounts', [ + 'user_id' => $editor->id, + 'driver' => 'google', + 'driver_id' => 'logintest123', + ]); + } + public function test_social_account_detach() { $editor = $this->users->editor(); config([ 'GITHUB_APP_ID' => 'abc123', 'GITHUB_APP_SECRET' => '123abc', - 'APP_URL' => 'http://localhost', ]); $socialAccount = SocialAccount::query()->forceCreate([ @@ -100,11 +128,11 @@ class SocialAuthTest extends TestCase 'driver_id' => 'logintest123', ]); - $resp = $this->actingAs($editor)->get($editor->getEditUrl()); + $resp = $this->actingAs($editor)->get('/my-account/auth'); $this->withHtml($resp)->assertElementContains('form[action$="/login/service/github/detach"]', 'Disconnect Account'); $resp = $this->post('/login/service/github/detach'); - $resp->assertRedirect($editor->getEditUrl()); + $resp->assertRedirect('/my-account/auth#social-accounts'); $resp = $this->followRedirects($resp); $resp->assertSee('Github account was successfully disconnected from your profile.'); @@ -115,7 +143,6 @@ class SocialAuthTest extends TestCase { config([ 'services.google.client_id' => 'abc123', 'services.google.client_secret' => '123abc', - 'APP_URL' => 'http://localhost', ]); $user = User::factory()->make(); @@ -153,7 +180,7 @@ class SocialAuthTest extends TestCase { config([ 'services.google.client_id' => 'abc123', 'services.google.client_secret' => '123abc', - 'APP_URL' => 'http://localhost', 'services.google.auto_register' => true, 'services.google.auto_confirm' => true, + 'services.google.auto_register' => true, 'services.google.auto_confirm' => true, ]); $user = User::factory()->make(); @@ -191,7 +218,7 @@ class SocialAuthTest extends TestCase $user = User::factory()->make(['email' => 'nonameuser@example.com']); $this->setSettings(['registration-enabled' => 'true']); - config(['GITHUB_APP_ID' => 'abc123', 'GITHUB_APP_SECRET' => '123abc', 'APP_URL' => 'http://localhost']); + config(['GITHUB_APP_ID' => 'abc123', 'GITHUB_APP_SECRET' => '123abc']); $mockSocialite = $this->mock(Factory::class); $mockSocialDriver = Mockery::mock(Provider::class); diff --git a/tests/Permissions/RolePermissionsTest.php b/tests/Permissions/RolePermissionsTest.php index 0b2e16686..d15c1617c 100644 --- a/tests/Permissions/RolePermissionsTest.php +++ b/tests/Permissions/RolePermissionsTest.php @@ -44,14 +44,12 @@ class RolePermissionsTest extends TestCase public function test_user_cannot_change_email_unless_they_have_manage_users_permission() { - $userProfileUrl = '/settings/users/' . $this->user->id; $originalEmail = $this->user->email; $this->actingAs($this->user); - $resp = $this->get($userProfileUrl) - ->assertOk(); + $resp = $this->get('/my-account/profile')->assertOk(); $this->withHtml($resp)->assertElementExists('input[name=email][disabled]'); - $this->put($userProfileUrl, [ + $this->put('/my-account/profile', [ 'name' => 'my_new_name', 'email' => 'new_email@example.com', ]); @@ -63,11 +61,12 @@ class RolePermissionsTest extends TestCase $this->permissions->grantUserRolePermissions($this->user, ['users-manage']); - $resp = $this->get($userProfileUrl) - ->assertOk(); - $this->withHtml($resp)->assertElementNotExists('input[name=email][disabled]') + $resp = $this->get('/my-account/profile')->assertOk(); + $this->withHtml($resp) + ->assertElementNotExists('input[name=email][disabled]') ->assertElementExists('input[name=email]'); - $this->put($userProfileUrl, [ + + $this->put('/my-account/profile', [ 'name' => 'my_new_name_2', 'email' => 'new_email@example.com', ]); diff --git a/tests/Uploads/ImageTest.php b/tests/Uploads/ImageTest.php index 4da964d48..af249951f 100644 --- a/tests/Uploads/ImageTest.php +++ b/tests/Uploads/ImageTest.php @@ -607,7 +607,7 @@ class ImageTest extends TestCase $this->actingAs($editor); $file = $this->getTestProfileImage(); - $this->call('PUT', '/settings/users/' . $editor->id, [], [], ['profile_image' => $file], []); + $this->call('PUT', '/my-account/profile', [], [], ['profile_image' => $file], []); $profileImages = Image::where('type', '=', 'user')->where('created_by', '=', $editor->id)->get(); $this->assertTrue($profileImages->count() === 1, 'Found profile images does not match upload count'); @@ -615,7 +615,7 @@ class ImageTest extends TestCase $imagePath = public_path($profileImages->first()->path); $this->assertTrue(file_exists($imagePath)); - $userDelete = $this->asAdmin()->delete("/settings/users/{$editor->id}"); + $userDelete = $this->asAdmin()->delete($editor->getEditUrl()); $userDelete->assertStatus(302); $this->assertDatabaseMissing('images', [ diff --git a/tests/User/UserApiTokenTest.php b/tests/User/UserApiTokenTest.php index 75de49aed..d94e97659 100644 --- a/tests/User/UserApiTokenTest.php +++ b/tests/User/UserApiTokenTest.php @@ -5,25 +5,26 @@ namespace Tests\User; use BookStack\Activity\ActivityType; use BookStack\Api\ApiToken; use Carbon\Carbon; +use Illuminate\Support\Facades\Hash; use Tests\TestCase; class UserApiTokenTest extends TestCase { - protected $testTokenData = [ + protected array $testTokenData = [ 'name' => 'My test API token', 'expires_at' => '2050-04-01', ]; - public function test_tokens_section_not_visible_without_access_api_permission() + public function test_tokens_section_not_visible_in_my_account_without_access_api_permission() { $user = $this->users->viewer(); - $resp = $this->actingAs($user)->get($user->getEditUrl()); + $resp = $this->actingAs($user)->get('/my-account/auth'); $resp->assertDontSeeText('API Tokens'); $this->permissions->grantUserRolePermissions($user, ['access-api']); - $resp = $this->actingAs($user)->get($user->getEditUrl()); + $resp = $this->actingAs($user)->get('/my-account/auth'); $resp->assertSeeText('API Tokens'); $resp->assertSeeText('Create Token'); } @@ -43,14 +44,14 @@ class UserApiTokenTest extends TestCase { $editor = $this->users->editor(); - $resp = $this->asAdmin()->get($editor->getEditUrl('/create-api-token')); + $resp = $this->asAdmin()->get("/api-tokens/{$editor->id}/create"); $resp->assertStatus(200); $resp->assertSee('Create API Token'); $resp->assertSee('Token Secret'); - $resp = $this->post($editor->getEditUrl('/create-api-token'), $this->testTokenData); + $resp = $this->post("/api-tokens/{$editor->id}/create", $this->testTokenData); $token = ApiToken::query()->latest()->first(); - $resp->assertRedirect($editor->getEditUrl('/api-tokens/' . $token->id)); + $resp->assertRedirect("/api-tokens/{$editor->id}/{$token->id}"); $this->assertDatabaseHas('api_tokens', [ 'user_id' => $editor->id, 'name' => $this->testTokenData['name'], @@ -63,7 +64,7 @@ class UserApiTokenTest extends TestCase $this->assertDatabaseMissing('api_tokens', [ 'secret' => $secret, ]); - $this->assertTrue(\Hash::check($secret, $token->secret)); + $this->assertTrue(Hash::check($secret, $token->secret)); $this->assertTrue(strlen($token->token_id) === 32); $this->assertTrue(strlen($secret) === 32); @@ -75,7 +76,10 @@ class UserApiTokenTest extends TestCase public function test_create_with_no_expiry_sets_expiry_hundred_years_away() { $editor = $this->users->editor(); - $this->asAdmin()->post($editor->getEditUrl('/create-api-token'), ['name' => 'No expiry token', 'expires_at' => '']); + + $resp = $this->asAdmin()->post("/api-tokens/{$editor->id}/create", ['name' => 'No expiry token', 'expires_at' => '']); + $resp->assertRedirect(); + $token = ApiToken::query()->latest()->first(); $over = Carbon::now()->addYears(101); @@ -89,7 +93,9 @@ class UserApiTokenTest extends TestCase public function test_created_token_displays_on_profile_page() { $editor = $this->users->editor(); - $this->asAdmin()->post($editor->getEditUrl('/create-api-token'), $this->testTokenData); + $resp = $this->asAdmin()->post("/api-tokens/{$editor->id}/create", $this->testTokenData); + $resp->assertRedirect(); + $token = ApiToken::query()->latest()->first(); $resp = $this->get($editor->getEditUrl()); @@ -102,28 +108,29 @@ class UserApiTokenTest extends TestCase public function test_secret_shown_once_after_creation() { $editor = $this->users->editor(); - $resp = $this->asAdmin()->followingRedirects()->post($editor->getEditUrl('/create-api-token'), $this->testTokenData); + $resp = $this->asAdmin()->followingRedirects()->post("/api-tokens/{$editor->id}/create", $this->testTokenData); $resp->assertSeeText('Token Secret'); $token = ApiToken::query()->latest()->first(); $this->assertNull(session('api-token-secret:' . $token->id)); - $resp = $this->get($editor->getEditUrl('/api-tokens/' . $token->id)); + $resp = $this->get("/api-tokens/{$editor->id}/{$token->id}"); + $resp->assertOk(); $resp->assertDontSeeText('Client Secret'); } public function test_token_update() { $editor = $this->users->editor(); - $this->asAdmin()->post($editor->getEditUrl('/create-api-token'), $this->testTokenData); + $this->asAdmin()->post("/api-tokens/{$editor->id}/create", $this->testTokenData); $token = ApiToken::query()->latest()->first(); $updateData = [ 'name' => 'My updated token', 'expires_at' => '2011-01-01', ]; - $resp = $this->put($editor->getEditUrl('/api-tokens/' . $token->id), $updateData); - $resp->assertRedirect($editor->getEditUrl('/api-tokens/' . $token->id)); + $resp = $this->put("/api-tokens/{$editor->id}/{$token->id}", $updateData); + $resp->assertRedirect("/api-tokens/{$editor->id}/{$token->id}"); $this->assertDatabaseHas('api_tokens', array_merge($updateData, ['id' => $token->id])); $this->assertSessionHas('success'); @@ -133,13 +140,13 @@ class UserApiTokenTest extends TestCase public function test_token_update_with_blank_expiry_sets_to_hundred_years_away() { $editor = $this->users->editor(); - $this->asAdmin()->post($editor->getEditUrl('/create-api-token'), $this->testTokenData); + $this->asAdmin()->post("/api-tokens/{$editor->id}/create", $this->testTokenData); $token = ApiToken::query()->latest()->first(); - $resp = $this->put($editor->getEditUrl('/api-tokens/' . $token->id), [ + $this->put("/api-tokens/{$editor->id}/{$token->id}", [ 'name' => 'My updated token', 'expires_at' => '', - ]); + ])->assertRedirect(); $token->refresh(); $over = Carbon::now()->addYears(101); @@ -153,15 +160,15 @@ class UserApiTokenTest extends TestCase public function test_token_delete() { $editor = $this->users->editor(); - $this->asAdmin()->post($editor->getEditUrl('/create-api-token'), $this->testTokenData); + $this->asAdmin()->post("/api-tokens/{$editor->id}/create", $this->testTokenData); $token = ApiToken::query()->latest()->first(); - $tokenUrl = $editor->getEditUrl('/api-tokens/' . $token->id); + $tokenUrl = "/api-tokens/{$editor->id}/{$token->id}"; $resp = $this->get($tokenUrl . '/delete'); $resp->assertSeeText('Delete Token'); $resp->assertSeeText($token->name); - $this->withHtml($resp)->assertElementExists('form[action="' . $tokenUrl . '"]'); + $this->withHtml($resp)->assertElementExists('form[action$="' . $tokenUrl . '"]'); $resp = $this->delete($tokenUrl); $resp->assertRedirect($editor->getEditUrl('#api_tokens')); @@ -175,15 +182,46 @@ class UserApiTokenTest extends TestCase $editor = $this->users->editor(); $this->permissions->grantUserRolePermissions($editor, ['users-manage']); - $this->asAdmin()->post($viewer->getEditUrl('/create-api-token'), $this->testTokenData); + $this->asAdmin()->post("/api-tokens/{$viewer->id}/create", $this->testTokenData); $token = ApiToken::query()->latest()->first(); - $resp = $this->actingAs($editor)->get($viewer->getEditUrl('/api-tokens/' . $token->id)); + $resp = $this->actingAs($editor)->get("/api-tokens/{$viewer->id}/{$token->id}"); $resp->assertStatus(200); $resp->assertSeeText('Delete Token'); - $resp = $this->actingAs($editor)->delete($viewer->getEditUrl('/api-tokens/' . $token->id)); + $resp = $this->actingAs($editor)->delete("/api-tokens/{$viewer->id}/{$token->id}"); $resp->assertRedirect($viewer->getEditUrl('#api_tokens')); $this->assertDatabaseMissing('api_tokens', ['id' => $token->id]); } + + public function test_return_routes_change_depending_on_entry_context() + { + $user = $this->users->admin(); + $returnByContext = [ + 'settings' => url("/settings/users/{$user->id}/#api_tokens"), + 'my-account' => url('/my-account/auth#api_tokens'), + ]; + + foreach ($returnByContext as $context => $returnUrl) { + $resp = $this->actingAs($user)->get("/api-tokens/{$user->id}/create?context={$context}"); + $this->withHtml($resp)->assertLinkExists($returnUrl, 'Cancel'); + + $this->post("/api-tokens/{$user->id}/create", $this->testTokenData); + $token = $user->apiTokens()->latest()->first(); + + $resp = $this->get($token->getUrl()); + $this->withHtml($resp)->assertLinkExists($returnUrl, 'Back'); + + $resp = $this->delete($token->getUrl()); + $resp->assertRedirect($returnUrl); + } + } + + public function test_context_assumed_for_editing_tokens_of_another_user() + { + $user = $this->users->viewer(); + + $resp = $this->asAdmin()->get("/api-tokens/{$user->id}/create?context=my-account"); + $this->withHtml($resp)->assertLinkExists($user->getEditUrl('#api_tokens'), 'Cancel'); + } } diff --git a/tests/User/UserMyAccountTest.php b/tests/User/UserMyAccountTest.php new file mode 100644 index 000000000..63c54daad --- /dev/null +++ b/tests/User/UserMyAccountTest.php @@ -0,0 +1,174 @@ +asEditor()->get('/my-account'); + $resp->assertRedirect('/my-account/profile'); + } + + public function test_views_not_accessible_to_guest_user() + { + $categories = ['profile', 'auth', 'shortcuts', 'notifications', '']; + $this->setSettings(['app-public' => 'true']); + + $this->permissions->grantUserRolePermissions($this->users->guest(), ['receive-notifications']); + + foreach ($categories as $category) { + $resp = $this->get('/my-account/' . $category); + $resp->assertRedirect('/'); + } + } + public function test_interface_shortcuts_updating() + { + $this->asEditor(); + + // View preferences with defaults + $resp = $this->get('/my-account/shortcuts'); + $resp->assertSee('UI Shortcut Preferences'); + + $html = $this->withHtml($resp); + $html->assertFieldHasValue('enabled', 'false'); + $html->assertFieldHasValue('shortcut[home_view]', '1'); + + // Update preferences + $resp = $this->put('/my-account/shortcuts', [ + 'enabled' => 'true', + 'shortcut' => ['home_view' => 'Ctrl + 1'], + ]); + + $resp->assertRedirect('/my-account/shortcuts'); + $resp->assertSessionHas('success', 'Shortcut preferences have been updated!'); + + // View updates to preferences page + $resp = $this->get('/my-account/shortcuts'); + $html = $this->withHtml($resp); + $html->assertFieldHasValue('enabled', 'true'); + $html->assertFieldHasValue('shortcut[home_view]', 'Ctrl + 1'); + } + + public function test_body_has_shortcuts_component_when_active() + { + $editor = $this->users->editor(); + $this->actingAs($editor); + + $this->withHtml($this->get('/'))->assertElementNotExists('body[component="shortcuts"]'); + + setting()->putUser($editor, 'ui-shortcuts-enabled', 'true'); + $this->withHtml($this->get('/'))->assertElementExists('body[component="shortcuts"]'); + } + + public function test_notification_routes_requires_notification_permission() + { + $viewer = $this->users->viewer(); + $resp = $this->actingAs($viewer)->get('/my-account/notifications'); + $this->assertPermissionError($resp); + + $resp = $this->actingAs($viewer)->get('/my-account/profile'); + $resp->assertDontSeeText('Notification Preferences'); + + $resp = $this->put('/my-account/notifications'); + $this->assertPermissionError($resp); + + $this->permissions->grantUserRolePermissions($viewer, ['receive-notifications']); + $resp = $this->get('/my-account/notifications'); + $resp->assertOk(); + $resp->assertSee('Notification Preferences'); + } + + public function test_notification_preferences_updating() + { + $editor = $this->users->editor(); + + // View preferences with defaults + $resp = $this->actingAs($editor)->get('/my-account/notifications'); + $resp->assertSee('Notification Preferences'); + + $html = $this->withHtml($resp); + $html->assertFieldHasValue('preferences[comment-replies]', 'false'); + + // Update preferences + $resp = $this->put('/my-account/notifications', [ + 'preferences' => ['comment-replies' => 'true'], + ]); + + $resp->assertRedirect('/my-account/notifications'); + $resp->assertSessionHas('success', 'Notification preferences have been updated!'); + + // View updates to preferences page + $resp = $this->get('/my-account/notifications'); + $html = $this->withHtml($resp); + $html->assertFieldHasValue('preferences[comment-replies]', 'true'); + } + + public function test_notification_preferences_show_watches() + { + $editor = $this->users->editor(); + $book = $this->entities->book(); + + $options = new UserEntityWatchOptions($editor, $book); + $options->updateLevelByValue(WatchLevels::COMMENTS); + + $resp = $this->actingAs($editor)->get('/my-account/notifications'); + $resp->assertSee($book->name); + $resp->assertSee('All Page Updates & Comments'); + + $options->updateLevelByValue(WatchLevels::DEFAULT); + + $resp = $this->actingAs($editor)->get('/my-account/notifications'); + $resp->assertDontSee($book->name); + $resp->assertDontSee('All Page Updates & Comments'); + } + + public function test_notification_preferences_dont_error_on_deleted_items() + { + $editor = $this->users->editor(); + $book = $this->entities->book(); + + $options = new UserEntityWatchOptions($editor, $book); + $options->updateLevelByValue(WatchLevels::COMMENTS); + + $this->actingAs($editor)->delete($book->getUrl()); + $book->refresh(); + $this->assertNotNull($book->deleted_at); + + $resp = $this->actingAs($editor)->get('/my-account/notifications'); + $resp->assertOk(); + $resp->assertDontSee($book->name); + } + + public function test_notification_preferences_not_accessible_to_guest() + { + $this->setSettings(['app-public' => 'true']); + $guest = $this->users->guest(); + $this->permissions->grantUserRolePermissions($guest, ['receive-notifications']); + + $resp = $this->get('/my-account/notifications'); + $this->assertPermissionError($resp); + + $resp = $this->put('/my-account/notifications', [ + 'preferences' => ['comment-replies' => 'true'], + ]); + $this->assertPermissionError($resp); + } + + public function test_notification_comment_options_only_exist_if_comments_active() + { + $resp = $this->asEditor()->get('/my-account/notifications'); + $resp->assertSee('Notify upon comments'); + $resp->assertSee('Notify upon replies'); + + setting()->put('app-disable-comments', true); + + $resp = $this->get('/my-account/notifications'); + $resp->assertDontSee('Notify upon comments'); + $resp->assertDontSee('Notify upon replies'); + } +} diff --git a/tests/User/UserPreferencesTest.php b/tests/User/UserPreferencesTest.php index 4a6cba7b3..d78ac2ea7 100644 --- a/tests/User/UserPreferencesTest.php +++ b/tests/User/UserPreferencesTest.php @@ -8,167 +8,6 @@ use Tests\TestCase; class UserPreferencesTest extends TestCase { - public function test_index_view() - { - $resp = $this->asEditor()->get('/preferences'); - $resp->assertOk(); - $resp->assertSee('Interface Keyboard Shortcuts'); - $resp->assertSee('Edit Profile'); - } - - public function test_index_view_accessible_but_without_profile_and_notifications_for_guest_user() - { - $this->setSettings(['app-public' => 'true']); - $this->permissions->grantUserRolePermissions($this->users->guest(), ['receive-notifications']); - $resp = $this->get('/preferences'); - $resp->assertOk(); - $resp->assertSee('Interface Keyboard Shortcuts'); - $resp->assertDontSee('Edit Profile'); - $resp->assertDontSee('Notification'); - } - public function test_interface_shortcuts_updating() - { - $this->asEditor(); - - // View preferences with defaults - $resp = $this->get('/preferences/shortcuts'); - $resp->assertSee('Interface Keyboard Shortcuts'); - - $html = $this->withHtml($resp); - $html->assertFieldHasValue('enabled', 'false'); - $html->assertFieldHasValue('shortcut[home_view]', '1'); - - // Update preferences - $resp = $this->put('/preferences/shortcuts', [ - 'enabled' => 'true', - 'shortcut' => ['home_view' => 'Ctrl + 1'], - ]); - - $resp->assertRedirect('/preferences/shortcuts'); - $resp->assertSessionHas('success', 'Shortcut preferences have been updated!'); - - // View updates to preferences page - $resp = $this->get('/preferences/shortcuts'); - $html = $this->withHtml($resp); - $html->assertFieldHasValue('enabled', 'true'); - $html->assertFieldHasValue('shortcut[home_view]', 'Ctrl + 1'); - } - - public function test_body_has_shortcuts_component_when_active() - { - $editor = $this->users->editor(); - $this->actingAs($editor); - - $this->withHtml($this->get('/'))->assertElementNotExists('body[component="shortcuts"]'); - - setting()->putUser($editor, 'ui-shortcuts-enabled', 'true'); - $this->withHtml($this->get('/'))->assertElementExists('body[component="shortcuts"]'); - } - - public function test_notification_routes_requires_notification_permission() - { - $viewer = $this->users->viewer(); - $resp = $this->actingAs($viewer)->get('/preferences/notifications'); - $this->assertPermissionError($resp); - - $resp = $this->put('/preferences/notifications'); - $this->assertPermissionError($resp); - - $this->permissions->grantUserRolePermissions($viewer, ['receive-notifications']); - $resp = $this->get('/preferences/notifications'); - $resp->assertOk(); - $resp->assertSee('Notification Preferences'); - } - - public function test_notification_preferences_updating() - { - $editor = $this->users->editor(); - - // View preferences with defaults - $resp = $this->actingAs($editor)->get('/preferences/notifications'); - $resp->assertSee('Notification Preferences'); - - $html = $this->withHtml($resp); - $html->assertFieldHasValue('preferences[comment-replies]', 'false'); - - // Update preferences - $resp = $this->put('/preferences/notifications', [ - 'preferences' => ['comment-replies' => 'true'], - ]); - - $resp->assertRedirect('/preferences/notifications'); - $resp->assertSessionHas('success', 'Notification preferences have been updated!'); - - // View updates to preferences page - $resp = $this->get('/preferences/notifications'); - $html = $this->withHtml($resp); - $html->assertFieldHasValue('preferences[comment-replies]', 'true'); - } - - public function test_notification_preferences_show_watches() - { - $editor = $this->users->editor(); - $book = $this->entities->book(); - - $options = new UserEntityWatchOptions($editor, $book); - $options->updateLevelByValue(WatchLevels::COMMENTS); - - $resp = $this->actingAs($editor)->get('/preferences/notifications'); - $resp->assertSee($book->name); - $resp->assertSee('All Page Updates & Comments'); - - $options->updateLevelByValue(WatchLevels::DEFAULT); - - $resp = $this->actingAs($editor)->get('/preferences/notifications'); - $resp->assertDontSee($book->name); - $resp->assertDontSee('All Page Updates & Comments'); - } - - public function test_notification_preferences_dont_error_on_deleted_items() - { - $editor = $this->users->editor(); - $book = $this->entities->book(); - - $options = new UserEntityWatchOptions($editor, $book); - $options->updateLevelByValue(WatchLevels::COMMENTS); - - $this->actingAs($editor)->delete($book->getUrl()); - $book->refresh(); - $this->assertNotNull($book->deleted_at); - - $resp = $this->actingAs($editor)->get('/preferences/notifications'); - $resp->assertOk(); - $resp->assertDontSee($book->name); - } - - public function test_notification_preferences_not_accessible_to_guest() - { - $this->setSettings(['app-public' => 'true']); - $guest = $this->users->guest(); - $this->permissions->grantUserRolePermissions($guest, ['receive-notifications']); - - $resp = $this->get('/preferences/notifications'); - $this->assertPermissionError($resp); - - $resp = $this->put('/preferences/notifications', [ - 'preferences' => ['comment-replies' => 'true'], - ]); - $this->assertPermissionError($resp); - } - - public function test_notification_comment_options_only_exist_if_comments_active() - { - $resp = $this->asEditor()->get('/preferences/notifications'); - $resp->assertSee('Notify upon comments'); - $resp->assertSee('Notify upon replies'); - - setting()->put('app-disable-comments', true); - - $resp = $this->get('/preferences/notifications'); - $resp->assertDontSee('Notify upon comments'); - $resp->assertDontSee('Notify upon replies'); - } - public function test_update_sort_preference() { $editor = $this->users->editor(); From f55e7ca3c9c4a67c457318c3747b685dbfb38ddc Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 19 Oct 2023 15:24:48 +0100 Subject: [PATCH 10/12] User Account: Ensured page titles for pages and api tokens --- app/Api/UserApiTokenController.php | 6 ++++++ app/Users/Controllers/UserAccountController.php | 2 ++ 2 files changed, 8 insertions(+) diff --git a/app/Api/UserApiTokenController.php b/app/Api/UserApiTokenController.php index b77e39089..3606e8260 100644 --- a/app/Api/UserApiTokenController.php +++ b/app/Api/UserApiTokenController.php @@ -22,6 +22,8 @@ class UserApiTokenController extends Controller $user = User::query()->findOrFail($userId); + $this->setPageTitle(trans('settings.user_api_token_create')); + return view('users.api-tokens.create', [ 'user' => $user, 'back' => $this->getRedirectPath($user), @@ -74,6 +76,8 @@ class UserApiTokenController extends Controller [$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId); $secret = session()->pull('api-token-secret:' . $token->id, null); + $this->setPageTitle(trans('settings.user_api_token')); + return view('users.api-tokens.edit', [ 'user' => $user, 'token' => $token, @@ -111,6 +115,8 @@ class UserApiTokenController extends Controller { [$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId); + $this->setPageTitle(trans('settings.user_api_token_delete')); + return view('users.api-tokens.delete', [ 'user' => $user, 'token' => $token, diff --git a/app/Users/Controllers/UserAccountController.php b/app/Users/Controllers/UserAccountController.php index 2ff58ffac..6bf23df47 100644 --- a/app/Users/Controllers/UserAccountController.php +++ b/app/Users/Controllers/UserAccountController.php @@ -40,6 +40,8 @@ class UserAccountController extends Controller */ public function showProfile() { + $this->setPageTitle(trans('preferences.profile')); + return view('users.account.profile', [ 'model' => user(), 'category' => 'profile', From ce53f641ada295200ad2f14683d2e74fbaa443ab Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 19 Oct 2023 16:06:59 +0100 Subject: [PATCH 11/12] My Account: Covered profile and auth pages with tests --- .../Controllers/UserAccountController.php | 2 +- tests/Permissions/RolePermissionsTest.php | 1 + tests/User/UserMyAccountTest.php | 165 ++++++++++++++++++ 3 files changed, 167 insertions(+), 1 deletion(-) diff --git a/app/Users/Controllers/UserAccountController.php b/app/Users/Controllers/UserAccountController.php index 6bf23df47..d9cb58f8c 100644 --- a/app/Users/Controllers/UserAccountController.php +++ b/app/Users/Controllers/UserAccountController.php @@ -161,7 +161,7 @@ class UserAccountController extends Controller */ public function showAuth(SocialAuthService $socialAuthService) { - $mfaMethods = user()->mfaValues->groupBy('method'); + $mfaMethods = user()->mfaValues()->get()->groupBy('method'); $this->setPageTitle(trans('preferences.auth')); diff --git a/tests/Permissions/RolePermissionsTest.php b/tests/Permissions/RolePermissionsTest.php index d15c1617c..ccb158faf 100644 --- a/tests/Permissions/RolePermissionsTest.php +++ b/tests/Permissions/RolePermissionsTest.php @@ -49,6 +49,7 @@ class RolePermissionsTest extends TestCase $resp = $this->get('/my-account/profile')->assertOk(); $this->withHtml($resp)->assertElementExists('input[name=email][disabled]'); + $resp->assertSee('Unfortunately you don\'t have permission to change your email address.'); $this->put('/my-account/profile', [ 'name' => 'my_new_name', 'email' => 'new_email@example.com', diff --git a/tests/User/UserMyAccountTest.php b/tests/User/UserMyAccountTest.php index 63c54daad..e1b40dadd 100644 --- a/tests/User/UserMyAccountTest.php +++ b/tests/User/UserMyAccountTest.php @@ -2,8 +2,13 @@ namespace Tests\User; +use BookStack\Access\Mfa\MfaValue; use BookStack\Activity\Tools\UserEntityWatchOptions; use BookStack\Activity\WatchLevels; +use BookStack\Api\ApiToken; +use BookStack\Uploads\Image; +use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Str; use Tests\TestCase; class UserMyAccountTest extends TestCase @@ -26,6 +31,166 @@ class UserMyAccountTest extends TestCase $resp->assertRedirect('/'); } } + + public function test_profile_updating() + { + $editor = $this->users->editor(); + + $resp = $this->actingAs($editor)->get('/my-account/profile'); + $resp->assertSee('Profile Details'); + + $html = $this->withHtml($resp); + $html->assertFieldHasValue('name', $editor->name); + $html->assertFieldHasValue('email', $editor->email); + + $resp = $this->put('/my-account/profile', [ + 'name' => 'Barryius', + 'email' => 'barryius@example.com', + 'language' => 'fr', + ]); + + $resp->assertRedirect('/my-account/profile'); + $this->assertDatabaseHas('users', [ + 'name' => 'Barryius', + 'email' => $editor->email, // No email change due to not having permissions + ]); + $this->assertEquals(setting()->getUser($editor, 'language'), 'fr'); + } + + public function test_profile_user_avatar_update_and_reset() + { + $user = $this->users->viewer(); + $avatarFile = $this->files->uploadedImage('avatar-icon.png'); + + $this->assertEquals(0, $user->image_id); + + $upload = $this->actingAs($user)->call('PUT', "/my-account/profile", [ + 'name' => 'Barry Scott', + ], [], ['profile_image' => $avatarFile], []); + $upload->assertRedirect('/my-account/profile'); + + + $user->refresh(); + $this->assertNotEquals(0, $user->image_id); + /** @var Image $image */ + $image = Image::query()->findOrFail($user->image_id); + $this->assertFileExists(public_path($image->path)); + + $reset = $this->put("/my-account/profile", [ + 'name' => 'Barry Scott', + 'profile_image_reset' => 'true', + ]); + $upload->assertRedirect('/my-account/profile'); + + $user->refresh(); + $this->assertFileDoesNotExist(public_path($image->path)); + $this->assertEquals(0, $user->image_id); + } + + public function test_profile_admin_options_link_shows_if_permissions_allow() + { + $editor = $this->users->editor(); + + $resp = $this->actingAs($editor)->get('/my-account/profile'); + $resp->assertDontSee('Administrator Options'); + $this->withHtml($resp)->assertLinkNotExists(url("/settings/users/{$editor->id}")); + + $this->permissions->grantUserRolePermissions($editor, ['users-manage']); + + $resp = $this->actingAs($editor)->get('/my-account/profile'); + $resp->assertSee('Administrator Options'); + $this->withHtml($resp)->assertLinkExists(url("/settings/users/{$editor->id}")); + } + + public function test_profile_self_delete() + { + $editor = $this->users->editor(); + + $resp = $this->actingAs($editor)->get('/my-account/profile'); + $this->withHtml($resp)->assertLinkExists(url('/my-account/delete'), 'Delete Account'); + + $resp = $this->get('/my-account/delete'); + $resp->assertSee('Delete My Account'); + $this->withHtml($resp)->assertElementContains('form[action$="/my-account"] button', 'Confirm'); + + $resp = $this->delete('/my-account'); + $resp->assertRedirect('/'); + + $this->assertDatabaseMissing('users', ['id' => $editor->id]); + } + + public function test_profile_self_delete_shows_ownership_migration_if_can_manage_users() + { + $editor = $this->users->editor(); + + $resp = $this->actingAs($editor)->get('/my-account/delete'); + $resp->assertDontSee('Migrate Ownership'); + + $this->permissions->grantUserRolePermissions($editor, ['users-manage']); + + $resp = $this->actingAs($editor)->get('/my-account/delete'); + $resp->assertSee('Migrate Ownership'); + } + + public function test_auth_password_change() + { + $editor = $this->users->editor(); + + $resp = $this->actingAs($editor)->get('/my-account/auth'); + $resp->assertSee('Change Password'); + $this->withHtml($resp)->assertElementExists('form[action$="/my-account/auth/password"]'); + + $password = Str::random(); + $resp = $this->put('/my-account/auth/password', [ + 'password' => $password, + 'password-confirm' => $password, + ]); + $resp->assertRedirect('/my-account/auth'); + + $editor->refresh(); + $this->assertTrue(Hash::check($password, $editor->password)); + } + + public function test_auth_password_change_hides_if_not_using_email_auth() + { + $editor = $this->users->editor(); + + $resp = $this->actingAs($editor)->get('/my-account/auth'); + $resp->assertSee('Change Password'); + + config()->set('auth.method', 'oidc'); + + $resp = $this->actingAs($editor)->get('/my-account/auth'); + $resp->assertDontSee('Change Password'); + } + + public function test_auth_page_has_mfa_links() + { + $editor = $this->users->editor(); + $resp = $this->actingAs($editor)->get('/my-account/auth'); + $resp->assertSee('0 methods configured'); + $this->withHtml($resp)->assertLinkExists(url('/mfa/setup')); + + MfaValue::upsertWithValue($editor, 'totp', 'testval'); + + $resp = $this->get('/my-account/auth'); + $resp->assertSee('1 method configured'); + } + + public function test_auth_page_api_tokens() + { + $editor = $this->users->editor(); + $resp = $this->actingAs($editor)->get('/my-account/auth'); + $resp->assertSee('API Tokens'); + $this->withHtml($resp)->assertLinkExists(url("/api-tokens/{$editor->id}/create?context=my-account")); + + ApiToken::factory()->create(['user_id' => $editor->id, 'name' => 'My great token']); + $editor->unsetRelations(); + + $resp = $this->get('/my-account/auth'); + $resp->assertSee('My great token'); + } + public function test_interface_shortcuts_updating() { $this->asEditor(); From 02bfaffeb49c0209da1c8caf6cc8699ead2a721e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 19 Oct 2023 16:37:55 +0100 Subject: [PATCH 12/12] My Acount: Updated old preference url reference for watches --- resources/views/entities/watch-controls.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/entities/watch-controls.blade.php b/resources/views/entities/watch-controls.blade.php index 9389a6c5a..4a119c272 100644 --- a/resources/views/entities/watch-controls.blade.php +++ b/resources/views/entities/watch-controls.blade.php @@ -37,7 +37,7 @@ @endforeach
  • - {{ trans('entities.watch_change_default') }}