diff --git a/extensions/gdpr/js/src/forum/extenders/extendUserSettingsPage.tsx b/extensions/gdpr/js/src/forum/extenders/extendUserSettingsPage.tsx index 315bdb4057..c54a7b28a1 100644 --- a/extensions/gdpr/js/src/forum/extenders/extendUserSettingsPage.tsx +++ b/extensions/gdpr/js/src/forum/extenders/extendUserSettingsPage.tsx @@ -27,16 +27,18 @@ export default function extendUserSettingsPage() { override('flarum/forum/components/SettingsPage', 'dataItems', function (): ItemList { const items = new ItemList(); - items.add( - 'gdprErasure', -
-

{app.translator.trans('flarum-gdpr.forum.settings.request_erasure_help')}

- -
, - 50 - ); + if (this.user === app.session.user) { + items.add( + 'gdprErasure', +
+

{app.translator.trans('flarum-gdpr.forum.settings.request_erasure_help')}

+ +
, + 50 + ); + } items.add( 'gdprExport', diff --git a/extensions/nicknames/js/src/forum/components/NicknameModal.js b/extensions/nicknames/js/src/forum/components/ChangeNicknameModal.tsx similarity index 65% rename from extensions/nicknames/js/src/forum/components/NicknameModal.js rename to extensions/nicknames/js/src/forum/components/ChangeNicknameModal.tsx index ad73908663..673ad08b0b 100644 --- a/extensions/nicknames/js/src/forum/components/NicknameModal.js +++ b/extensions/nicknames/js/src/forum/components/ChangeNicknameModal.tsx @@ -1,17 +1,26 @@ import app from 'flarum/forum/app'; -import FormModal from 'flarum/common/components/FormModal'; +import FormModal, { IFormModalAttrs } from 'flarum/common/components/FormModal'; import Button from 'flarum/common/components/Button'; import Stream from 'flarum/common/utils/Stream'; import Form from 'flarum/common/components/Form'; -export default class NicknameModal extends FormModal { - oninit(vnode) { +import type Mithril from 'mithril'; +import type User from 'flarum/common/models/User'; + +export interface IChangeNicknameModalAttrs extends IFormModalAttrs { + user: User; +} + +export default class ChangeNicknameModal extends FormModal { + nickname!: Stream; + + oninit(vnode: Mithril.Vnode) { super.oninit(vnode); - this.nickname = Stream(app.session.user.displayName()); + this.nickname = Stream(this.attrs.user.displayName()); } className() { - return 'NickameModal Modal--small'; + return 'ChangeNicknameModal Modal--small'; } title() { @@ -35,17 +44,17 @@ export default class NicknameModal extends FormModal { ); } - onsubmit(e) { + onsubmit(e: Event) { e.preventDefault(); - if (this.nickname() === app.session.user.displayName()) { + if (this.nickname() === this.attrs.user.displayName()) { this.hide(); return; } this.loading = true; - app.session.user + this.attrs.user .save( { nickname: this.nickname() }, { diff --git a/extensions/nicknames/js/src/forum/index.js b/extensions/nicknames/js/src/forum/index.js index 73c7a78778..00fbb82cc1 100644 --- a/extensions/nicknames/js/src/forum/index.js +++ b/extensions/nicknames/js/src/forum/index.js @@ -3,7 +3,7 @@ import { extend } from 'flarum/common/extend'; import Button from 'flarum/common/components/Button'; import extractText from 'flarum/common/utils/extractText'; import Stream from 'flarum/common/utils/Stream'; -import NickNameModal from './components/NicknameModal'; +import ChangeNicknameModal from './components/ChangeNicknameModal'; export { default as extend } from './extend'; @@ -14,7 +14,7 @@ app.initializers.add('flarum-nicknames', () => { if (this.user.canEditNickname()) { items.add( 'changeNickname', - ); diff --git a/framework/core/js/src/forum/components/ChangeEmailModal.tsx b/framework/core/js/src/forum/components/ChangeEmailModal.tsx index 6f149bdf6d..f76cdf0c22 100644 --- a/framework/core/js/src/forum/components/ChangeEmailModal.tsx +++ b/framework/core/js/src/forum/components/ChangeEmailModal.tsx @@ -7,11 +7,16 @@ import RequestError from '../../common/utils/RequestError'; import ItemList from '../../common/utils/ItemList'; import Form from '../../common/components/Form'; +import type User from '../../common/models/User'; + +export interface IChangeEmailModalAttrs extends IFormModalAttrs { + user: User; +} /** * The `ChangeEmailModal` component shows a modal dialog which allows the user * to change their email address. */ -export default class ChangeEmailModal extends FormModal { +export default class ChangeEmailModal extends FormModal { /** * The value of the email input. */ @@ -30,7 +35,7 @@ export default class ChangeEmailModal) { super.oninit(vnode); - this.email = Stream(app.session.user!.email() || ''); + this.email = Stream(this.attrs.user.email() || ''); this.password = Stream(''); } @@ -75,31 +80,26 @@ export default class ChangeEmailModal - + ); - items.add( - 'password', -
- -
- ); + if (!app.session.user?.isAdmin()) { + items.add( + 'password', +
+ +
+ ); + } items.add( 'submit', @@ -119,7 +119,7 @@ export default class ChangeEmailModal { - this.success = true; + if (!app.session.user?.isAdmin()) { + this.success = true; + } else { + this.hide(); + } }) .catch(() => {}) .then(this.loaded.bind(this)); diff --git a/framework/core/js/src/forum/components/ChangePasswordModal.tsx b/framework/core/js/src/forum/components/RequestPasswordResetModal.tsx similarity index 62% rename from framework/core/js/src/forum/components/ChangePasswordModal.tsx rename to framework/core/js/src/forum/components/RequestPasswordResetModal.tsx index f84c9a2037..727c342c41 100644 --- a/framework/core/js/src/forum/components/ChangePasswordModal.tsx +++ b/framework/core/js/src/forum/components/RequestPasswordResetModal.tsx @@ -1,21 +1,29 @@ -import app from '../../forum/app'; +import app from '../app'; import FormModal, { IFormModalAttrs } from '../../common/components/FormModal'; import Button from '../../common/components/Button'; import Mithril from 'mithril'; import ItemList from '../../common/utils/ItemList'; import Form from '../../common/components/Form'; +import type User from '../../common/models/User'; + +export interface IRequestPasswordResetModalAttrs extends IFormModalAttrs { + user: User; +} + /** - * The `ChangePasswordModal` component shows a modal dialog which allows the + * The `RequestPasswordResetModal` component shows a modal dialog which allows the * user to send themself a password reset email. */ -export default class ChangePasswordModal extends FormModal { +export default class RequestPasswordResetModal< + CustomAttrs extends IRequestPasswordResetModalAttrs = IRequestPasswordResetModalAttrs +> extends FormModal { className() { - return 'ChangePasswordModal Modal--small'; + return 'RequestPasswordResetModal Modal--small'; } title() { - return app.translator.trans('core.forum.change_password.title'); + return app.translator.trans('core.forum.request_password_reset.title'); } content() { @@ -29,13 +37,13 @@ export default class ChangePasswordModal(); - fields.add('help',

{app.translator.trans('core.forum.change_password.text')}

); + fields.add('help',

{app.translator.trans('core.forum.request_password_reset.text')}

); fields.add( 'submit',
); @@ -58,6 +66,6 @@ export default class ChangePasswordModal + {app.translator.trans('core.forum.header.settings_button')} , 50 diff --git a/framework/core/js/src/forum/components/SettingsPage.tsx b/framework/core/js/src/forum/components/SettingsPage.tsx index 16c28499ff..f6fd0ff477 100644 --- a/framework/core/js/src/forum/components/SettingsPage.tsx +++ b/framework/core/js/src/forum/components/SettingsPage.tsx @@ -5,7 +5,7 @@ import Switch from '../../common/components/Switch'; import Button from '../../common/components/Button'; import FieldSet from '../../common/components/FieldSet'; import NotificationGrid from './NotificationGrid'; -import ChangePasswordModal from './ChangePasswordModal'; +import RequestPasswordResetModal from './RequestPasswordResetModal'; import ChangeEmailModal from './ChangeEmailModal'; import listItems from '../../common/helpers/listItems'; import extractText from '../../common/utils/extractText'; @@ -27,7 +27,13 @@ export default class SettingsPage) { super.oninit(vnode); - this.show(app.session.user!); + const routeUsername = m.route.param('username'); + + if (routeUsername !== app.session.user?.slug() && !app.session.user?.isAdmin()) { + m.route.set('/'); + } + + this.loadUser(routeUsername); app.setTitle(extractText(app.translator.trans('core.forum.settings.title'))); } @@ -86,16 +92,16 @@ export default class SettingsPage(); items.add( - 'changePassword', - , 100 ); items.add( 'changeEmail', - , 90 @@ -191,7 +197,11 @@ export default class SettingsPage { this.colorSchemeLoading = false; - app.setColorScheme(mode.id); + + if (this.user === app.session.user) { + app.setColorScheme(mode.id); + } + m.redraw(); }); }} diff --git a/framework/core/js/src/forum/components/UserPage.tsx b/framework/core/js/src/forum/components/UserPage.tsx index 636753f3c2..676ae38641 100644 --- a/framework/core/js/src/forum/components/UserPage.tsx +++ b/framework/core/js/src/forum/components/UserPage.tsx @@ -153,11 +153,11 @@ export default class UserPage, -90); items.add( 'settings', - + {app.translator.trans('core.forum.user.settings_link')} , -100 diff --git a/framework/core/js/src/forum/routes.ts b/framework/core/js/src/forum/routes.ts index 67f4992768..cf4f64c14b 100644 --- a/framework/core/js/src/forum/routes.ts +++ b/framework/core/js/src/forum/routes.ts @@ -35,9 +35,9 @@ export default function (app: ForumApplication) { component: () => import('./components/DiscussionsUserPage'), resolverClass: UserPageResolver, }, - - settings: { path: '/settings', component: () => import('./components/SettingsPage') }, + 'user.settings': { path: '/u/:username/settings', component: () => import('./components/SettingsPage'), resolverClass: UserPageResolver }, 'user.security': { path: '/u/:username/security', component: () => import('./components/UserSecurityPage'), resolverClass: UserPageResolver }, + notifications: { path: '/notifications', component: () => import('./components/NotificationsPage') }, }; } diff --git a/framework/core/js/tests/integration/forum/components/Modals.test.ts b/framework/core/js/tests/integration/forum/components/Modals.test.ts index baf9dbde10..ff0166743c 100644 --- a/framework/core/js/tests/integration/forum/components/Modals.test.ts +++ b/framework/core/js/tests/integration/forum/components/Modals.test.ts @@ -4,7 +4,7 @@ import { app } from '../../../../src/forum'; import ModalManager from '../../../../src/common/components/ModalManager'; import GlobalDiscussionsSearchSource from '../../../../src/forum/components/GlobalDiscussionsSearchSource'; import ChangeEmailModal from '../../../../src/forum/components/ChangeEmailModal'; -import ChangePasswordModal from '../../../../src/forum/components/ChangePasswordModal'; +import RequestPasswordResetModal from '../../../../src/forum/components/RequestPasswordResetModal'; import ForgotPasswordModal from '../../../../src/forum/components/ForgotPasswordModal'; import LogInModal from '../../../../src/forum/components/LogInModal'; import NewAccessTokenModal from '../../../../src/forum/components/NewAccessTokenModal'; @@ -21,7 +21,7 @@ describe('Modals', () => { test('ChangeEmailModal renders', () => { const manager = mq(ModalManager, { state: app.modal }); - app.modal.show(ChangeEmailModal); + app.modal.show(ChangeEmailModal, { user: app.session.user! }); manager.redraw(); @@ -29,10 +29,10 @@ describe('Modals', () => { expect(manager).toHaveElement('.ModalManager'); }); - test('ChangePasswordModal renders', () => { + test('RequestPasswordResetModal renders', () => { const manager = mq(ModalManager, { state: app.modal }); - app.modal.show(ChangePasswordModal); + app.modal.show(RequestPasswordResetModal, { user: app.session.user! }); manager.redraw(); diff --git a/framework/core/locale/core.yml b/framework/core/locale/core.yml index c9888b65f2..cb5a31970d 100644 --- a/framework/core/locale/core.yml +++ b/framework/core/locale/core.yml @@ -501,8 +501,8 @@ core: submit_button: => core.ref.save_changes title: => core.ref.change_email - # These translations are used in the Change Password modal dialog. - change_password: + # These translations are used in the Request Password Reset modal dialog. + request_password_reset: send_button: Send Password Reset Email text: Click the button below and check your email for a link to change your password. title: => core.ref.change_password @@ -721,7 +721,7 @@ core: settings: account_heading: Account change_email_button: => core.ref.change_email - change_password_button: => core.ref.change_password + request_password_reset_button: => core.ref.change_password color_scheme_heading: Color Scheme color_schemes: auto_mode_label: System preference diff --git a/framework/core/src/Api/Resource/UserResource.php b/framework/core/src/Api/Resource/UserResource.php index 59785b4c88..2ab5aaee06 100644 --- a/framework/core/src/Api/Resource/UserResource.php +++ b/framework/core/src/Api/Resource/UserResource.php @@ -277,8 +277,8 @@ public function fields(): array return $user->getNewNotificationCount(); }), Schema\Arr::make('preferences') - ->visible(fn (User $user, Context $context) => ($context->collection instanceof self || $context->collection instanceof ForumResource) && $context->getActor()->id === $user->id) - ->writable(fn (User $user, Context $context) => $context->getActor()->id === $user->id) + ->visible(fn (User $user, Context $context) => ($context->collection instanceof self || $context->collection instanceof ForumResource) && ($context->getActor()->id === $user->id || $context->getActor()->isAdmin())) + ->writable(fn (User $user, Context $context) => $context->getActor()->id === $user->id || $context->getActor()->isAdmin()) ->set(function (User $user, array $value) { foreach ($value as $k => $v) { $user->setPreference($k, $v); diff --git a/framework/core/src/Forum/Controller/UnsubscribeViewController.php b/framework/core/src/Forum/Controller/UnsubscribeViewController.php index 9a0e0962b9..642bff3b85 100644 --- a/framework/core/src/Forum/Controller/UnsubscribeViewController.php +++ b/framework/core/src/Forum/Controller/UnsubscribeViewController.php @@ -10,10 +10,12 @@ namespace Flarum\Forum\Controller; use Flarum\Http\Controller\AbstractHtmlController; +use Flarum\Http\SlugManager; use Flarum\Http\UrlGenerator; use Flarum\Locale\TranslatorInterface; use Flarum\Notification\UnsubscribeToken; use Flarum\Settings\SettingsRepositoryInterface; +use Flarum\User\User; use Illuminate\Contracts\View\Factory; use Illuminate\Contracts\View\View; use Illuminate\Support\Arr; @@ -25,7 +27,8 @@ public function __construct( protected Factory $view, protected UrlGenerator $url, protected TranslatorInterface $translator, - protected SettingsRepositoryInterface $settings + protected SettingsRepositoryInterface $settings, + protected SlugManager $slugManager, ) { } @@ -40,7 +43,10 @@ public function render(Request $request): View ->where('token', $token) ->first(); - $settingsLink = $this->url->to('forum')->route('settings'); + $user = User::find($userId); + $userSlug = $this->slugManager->forResource(User::class)->toSlug($user); + + $settingsLink = $this->url->to('forum')->route('user', ['username' => $userSlug, 'filter' => 'settings']); $forumTitle = $this->settings->get('forum_title'); // If record exists and has not been used before diff --git a/framework/core/src/Forum/routes.php b/framework/core/src/Forum/routes.php index 1d08c9a622..d6998e12d6 100644 --- a/framework/core/src/Forum/routes.php +++ b/framework/core/src/Forum/routes.php @@ -37,12 +37,6 @@ $route->toForum(Content\User::class) ); - $map->get( - '/settings', - 'settings', - $route->toForum(Content\AssertRegistered::class) - ); - $map->get( '/notifications', 'notifications', diff --git a/framework/core/src/Notification/NotificationMailer.php b/framework/core/src/Notification/NotificationMailer.php index 9d7034d8a5..c735be65f6 100644 --- a/framework/core/src/Notification/NotificationMailer.php +++ b/framework/core/src/Notification/NotificationMailer.php @@ -9,6 +9,7 @@ namespace Flarum\Notification; +use Flarum\Http\SlugManager; use Flarum\Http\UrlGenerator; use Flarum\Locale\TranslatorInterface; use Flarum\Notification\Blueprint\BlueprintInterface; @@ -27,6 +28,7 @@ public function __construct( protected SettingsRepositoryInterface $settings, protected UrlGenerator $url, protected Factory $view, + protected SlugManager $slugManager, ) { } @@ -39,8 +41,10 @@ public function send(MailableInterface&BlueprintInterface $blueprint, User $user $unsubscribeRecord = $this->generateUnsubscribeToken($user->id, $blueprint::getType()); $unsubscribeRecord->save(); + $userSlug = $this->slugManager->forResource(User::class)->toSlug($user); + $unsubscribeLink = $this->url->to('forum')->route('notifications.unsubscribe', ['userId' => $user->id, 'token' => $unsubscribeRecord->token]); - $settingsLink = $this->url->to('forum')->route('settings'); + $settingsLink = $this->url->to('forum')->route('user', ['username' => $userSlug, 'filter' => 'settings']); $type = $blueprint::getType(); $forumTitle = $this->settings->get('forum_title'); $username = $user->display_name; diff --git a/framework/core/tests/integration/api/users/UpdateTest.php b/framework/core/tests/integration/api/users/UpdateTest.php index 970af56342..16aeaa75be 100644 --- a/framework/core/tests/integration/api/users/UpdateTest.php +++ b/framework/core/tests/integration/api/users/UpdateTest.php @@ -611,7 +611,7 @@ public function users_cant_activate_others_even_with_permissions() } #[Test] - public function admins_cant_update_others_preferences() + public function admins_can_update_others_preferences() { $response = $this->send( $this->request('PATCH', '/api/users/2', [ @@ -628,7 +628,7 @@ public function admins_cant_update_others_preferences() ], ]) ); - $this->assertEquals(403, $response->getStatusCode()); + $this->assertEquals(200, $response->getStatusCode()); } #[Test] diff --git a/framework/core/tests/integration/forum/DefaultRouteTest.php b/framework/core/tests/integration/forum/DefaultRouteTest.php index fcf1acb7aa..5c5cf62b1b 100644 --- a/framework/core/tests/integration/forum/DefaultRouteTest.php +++ b/framework/core/tests/integration/forum/DefaultRouteTest.php @@ -73,7 +73,7 @@ public function nonexistent_custom_homepage_uses_default_payload() #[Test] public function existent_custom_homepage_doesnt_use_default_payload() { - $this->setDefaultRoute('/settings'); + $this->setDefaultRoute('/notifications'); $response = $this->send( $this->request('GET', '/') diff --git a/framework/core/tests/unit/Notification/NotificationMailerTest.php b/framework/core/tests/unit/Notification/NotificationMailerTest.php index 06b4662463..ac9c5bb8d4 100644 --- a/framework/core/tests/unit/Notification/NotificationMailerTest.php +++ b/framework/core/tests/unit/Notification/NotificationMailerTest.php @@ -10,6 +10,8 @@ namespace Flarum\Tests\unit\Notification; use Flarum\Http\RouteCollectionUrlGenerator; +use Flarum\Http\SlugDriverInterface; +use Flarum\Http\SlugManager; use Flarum\Http\UrlGenerator; use Flarum\Locale\TranslatorInterface; use Flarum\Notification\Blueprint\BlueprintInterface; @@ -32,6 +34,7 @@ class NotificationMailerTest extends TestCase private SettingsRepositoryInterface $settings; private UrlGenerator $url; private Factory $view; + private SlugManager $slugManager; private NotificationMailer $notificationMailer; protected function setUp(): void @@ -43,6 +46,7 @@ protected function setUp(): void $this->settings = m::mock(SettingsRepositoryInterface::class); $this->url = m::mock(UrlGenerator::class); $this->view = m::mock(Factory::class); + $this->slugManager = m::mock(SlugManager::class); // Common stub setup $this->translator->shouldReceive('setLocale')->once(); @@ -53,10 +57,14 @@ protected function setUp(): void $routeGenerator->shouldReceive('route')->andReturn('https://example.com/some-url'); $this->url->shouldReceive('to')->andReturn($routeGenerator); + $slugDriver = m::mock(SlugDriverInterface::class); + $slugDriver->shouldReceive('toSlug')->andReturn('test-user'); + $this->slugManager->shouldReceive('forResource')->with(User::class)->andReturn($slugDriver); + $this->view->shouldReceive('share')->once(); // Use a testable subclass that stubs out the DB-touching unsubscribe token - $this->notificationMailer = new class($this->mailer, $this->translator, $this->settings, $this->url, $this->view) extends NotificationMailer { + $this->notificationMailer = new class($this->mailer, $this->translator, $this->settings, $this->url, $this->view, $this->slugManager) extends NotificationMailer { protected function generateUnsubscribeToken(int $userId, string $emailType): UnsubscribeToken { $token = m::mock(UnsubscribeToken::class)->shouldIgnoreMissing();