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 24a04ef7e..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. @@ -214,6 +214,7 @@ class SocialAuthService /** * Gets the names of the active social drivers. + * @returns array */ public function getActiveDrivers(): array { 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/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..3606e8260 100644 --- a/app/Api/UserApiTokenController.php +++ b/app/Api/UserApiTokenController.php @@ -14,16 +14,19 @@ 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); + $this->setPageTitle(trans('settings.user_api_token_create')); + return view('users.api-tokens.create', [ 'user' => $user, + 'back' => $this->getRedirectPath($user), ]); } @@ -60,22 +63,27 @@ 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); + $this->setPageTitle(trans('settings.user_api_token')); + return view('users.api-tokens.edit', [ 'user' => $user, 'token' => $token, 'model' => $token, 'secret' => $secret, + 'back' => $this->getRedirectPath($user), ]); } @@ -97,7 +105,7 @@ class UserApiTokenController extends Controller $this->logActivity(ActivityType::API_TOKEN_UPDATE, $token); - return redirect($user->getEditUrl('/api-tokens/' . $token->id)); + return redirect($token->getUrl()); } /** @@ -107,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, @@ -123,7 +133,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 +152,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' || user()->id !== $relatedUser->id) { + return $relatedUser->getEditUrl('#api_tokens'); + } + + return url('/my-account/auth#api_tokens'); + } } diff --git a/app/Users/Controllers/UserAccountController.php b/app/Users/Controllers/UserAccountController.php new file mode 100644 index 000000000..d9cb58f8c --- /dev/null +++ b/app/Users/Controllers/UserAccountController.php @@ -0,0 +1,223 @@ +middleware(function (Request $request, Closure $next) { + $this->preventGuestAccess(); + $this->preventAccessInDemoMode(); + return $next($request); + }); + } + + /** + * 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 redirect() + { + return redirect('/my-account/profile'); + } + + /** + * Show the profile form interface. + */ + public function showProfile() + { + $this->setPageTitle(trans('preferences.profile')); + + 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. + */ + public function showShortcuts() + { + $shortcuts = UserShortcutMap::fromUserPreferences(); + $enabled = setting()->getForCurrentUser('ui-shortcuts-enabled', false); + + $this->setPageTitle(trans('preferences.shortcuts_interface')); + + return view('users.account.shortcuts', [ + 'category' => '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'); + + $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', [ + 'category' => 'notifications', + 'preferences' => $preferences, + 'watches' => $watches, + ]); + } + + /** + * Update the notification preferences for the current user. + */ + public function updateNotifications(Request $request) + { + $this->checkPermission('receive-notifications'); + $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'); + } + + /** + * Show the view for the "Access & Security" account options. + */ + public function showAuth(SocialAuthService $socialAuthService) + { + $mfaMethods = user()->mfaValues()->get()->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'); + } + + /** + * 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/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/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/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 118e8ba82..2b88f9671 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', @@ -29,5 +29,23 @@ 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.', - 'profile_overview_desc' => ' Manage your user profile details including preferred language and authentication options.', + '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_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.', + + '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/lang/en/settings.php b/lang/en/settings.php index 9f60606ac..9e49c7ca7 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -193,8 +193,8 @@ 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_password_warning' => 'Only fill the below if you would like to change your password.', + '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', 'users_delete_named' => 'Delete user :userName', @@ -210,12 +210,16 @@ 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_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', + '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/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/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/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') }}
  • diff --git a/resources/views/layouts/parts/header-user-menu.blade.php b/resources/views/layouts/parts/header-user-menu.blade.php index 8ba750f95..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.preferences') }}
    -
    -
  • -
  • - @include('common.dark-mode-toggle', ['classes' => 'icon-item']) -
  • \ 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..d6f85093b --- /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(), 'context' => 'my-account']) + @endif +@stop 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') + +
    +
    + {{ csrf_field() }} + {{ method_field('delete') }} + + +

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

    + +

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

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

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

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

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

    +
    + {{ trans('common.cancel') }} + +
    +
    + +
    +
    + +@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..f54a51c5a --- /dev/null +++ b/resources/views/users/account/layout.blade.php @@ -0,0 +1,29 @@ +@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/profile.blade.php b/resources/views/users/account/profile.blade.php new file mode 100644 index 000000000..617c09723 --- /dev/null +++ b/resources/views/users/account/profile.blade.php @@ -0,0 +1,91 @@ +@extends('users.account.layout') + +@section('main') + +
    +
    + {{ method_field('put') }} + {{ csrf_field() }} + +
    +

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

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

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

    + +
    + +
    +
    + +

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

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

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

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

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

    + @endif +
    + +
    +
    + +

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

    +
    +
    + @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()]) + +
    + +
    + {{ trans('preferences.delete_account') }} + +
    + +
    +
    + + @if(userCan('users-manage')) +
    +
    +
    +

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

    +

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

    +
    + +
    +
    + @endif +@stop 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/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 58617fb85..70aaa58f3 100644 --- a/resources/views/users/api-tokens/parts/list.blade.php +++ b/resources/views/users/api-tokens/parts/list.blade.php @@ -4,16 +4,17 @@ +

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

    @if (count($user->apiTokens) > 0)
    @foreach($user->apiTokens as $token)
    - {{ $token->name }}
    + {{ $token->name }}
    {{ $token->token_id }}
    @@ -22,7 +23,7 @@ {{ $token->expires_at->format('Y-m-d') ?? '' }}
    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/resources/views/users/edit.blade.php b/resources/views/users/edit.blade.php index 832186930..2b736d81e 100644 --- a/resources/views/users/edit.blade.php +++ b/resources/views/users/edit.blade.php @@ -38,7 +38,7 @@
    - {{ trans('common.cancel') }} @if($authMethod !== 'system') id}/delete") }}" @@ -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_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)) +

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

    + @else +

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

    + @endif
    @endforeach
    @@ -100,9 +100,7 @@
    @endif - @if((user()->id === $user->id && userCan('access-api')) || userCan('users-manage')) - @include('users.api-tokens.parts.list', ['user' => $user]) - @endif + @include('users.api-tokens.parts.list', ['user' => $user, 'context' => 'settings'])
    @stop diff --git a/resources/views/users/parts/form.blade.php b/resources/views/users/parts/form.blade.php index 7ff48a83d..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')
    @@ -64,7 +61,7 @@ @endif
    -

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

    +

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

    @if(isset($model))

    {{ trans('settings.users_password_warning') }} diff --git a/resources/views/users/preferences/index.blade.php b/resources/views/users/preferences/index.blade.php deleted file mode 100644 index f8576ed9e..000000000 --- a/resources/views/users/preferences/index.blade.php +++ /dev/null @@ -1,41 +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 - -
    -@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..c86509c68 100644 --- a/routes/web.php +++ b/routes/web.php @@ -232,26 +232,33 @@ 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, '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']); + 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::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']); 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']); - 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']); 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..ccb158faf 100644 --- a/tests/Permissions/RolePermissionsTest.php +++ b/tests/Permissions/RolePermissionsTest.php @@ -44,14 +44,13 @@ 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, [ + $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', ]); @@ -63,11 +62,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..e1b40dadd --- /dev/null +++ b/tests/User/UserMyAccountTest.php @@ -0,0 +1,339 @@ +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_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(); + + // 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();