From bb7a0489afd8fcb05b7c74317b7dc5af388719f7 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Mon, 12 Oct 2026 20:20:02 +0200 Subject: [PATCH 1/2] Document UsersMerging/UserRenamed events replacing legacy merge/rename listeners --- docs/migration/wsc62/deprecations_removals.md | 4 + docs/migration/wsc62/php.md | 78 +++++++++++++++++++ docs/tutorial/series/part_5.md | 24 +++--- .../tutorial-series/part-5/eventListener.xml | 12 --- .../lib/bootstrap/com.woltlab.wcf.people.php | 61 +++++++++++++++ .../PersonUserActionRenameListener.class.php | 20 ----- .../PersonUserMergeListener.class.php | 20 ----- .../tutorial-series/part-6/eventListener.xml | 12 --- .../lib/bootstrap/com.woltlab.wcf.people.php | 61 +++++++++++++++ .../PersonUserActionRenameListener.class.php | 20 ----- .../PersonUserMergeListener.class.php | 20 ----- 11 files changed, 214 insertions(+), 118 deletions(-) create mode 100644 snippets/tutorial/tutorial-series/part-5/files/lib/bootstrap/com.woltlab.wcf.people.php delete mode 100644 snippets/tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonUserActionRenameListener.class.php delete mode 100644 snippets/tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonUserMergeListener.class.php create mode 100644 snippets/tutorial/tutorial-series/part-6/files/lib/bootstrap/com.woltlab.wcf.people.php delete mode 100644 snippets/tutorial/tutorial-series/part-6/files/lib/system/event/listener/PersonUserActionRenameListener.class.php delete mode 100644 snippets/tutorial/tutorial-series/part-6/files/lib/system/event/listener/PersonUserMergeListener.class.php diff --git a/docs/migration/wsc62/deprecations_removals.md b/docs/migration/wsc62/deprecations_removals.md index 34aee8fab..efc1782d9 100644 --- a/docs/migration/wsc62/deprecations_removals.md +++ b/docs/migration/wsc62/deprecations_removals.md @@ -126,6 +126,10 @@ With version 6.3, we have deprecated certain components and removed several othe - `wcf\data\notice\NoticeEditor` - `wcf\data\ad\AdAction` - `wcf\data\ad\AdEditor` +- `wcf\acp\form\UserMergeForm` +- `wcf\system\event\listener\AbstractUserMergeListener` +- `wcf\system\event\listener\AbstractUserActionRenameListener` +- `wcf\data\user\UserEditor` #### Methods diff --git a/docs/migration/wsc62/php.md b/docs/migration/wsc62/php.md index 4d2032ce0..0d2cd08e4 100644 --- a/docs/migration/wsc62/php.md +++ b/docs/migration/wsc62/php.md @@ -391,3 +391,81 @@ A condition like `userID NOT IN (?)` excludes all events of guests and must be w Implementations of `IUserActivityEvent` that require a registered user, for example to look up the author of a comment, must handle guests. The `TCommentResponseUserActivityEvent` trait provides `getCommentAuthor()` that returns a guest profile for comments written by guests. + +## User Merging + +The ACP form `wcf\acp\form\UserMergeForm` has been replaced by a dialog of the bulk interactions in the user list, the users are merged by the command `wcf\command\user\MergeUsers`. +`UserMergeForm` and `wcf\system\event\listener\AbstractUserMergeListener` are deprecated. +Event listeners for the `save` event of `UserMergeForm` are still executed, but should be migrated. + +The PSR-14 event `wcf\event\user\UsersMerging` is fired before the merged users are deleted. +Instead of extending `AbstractUserMergeListener`, a bootstrap script registers the database columns that reference users through `UsersMerging::reassignUserColumn()`. +Unlike `AbstractUserMergeListener`, the column containing the username is not updated unless it is explicitly passed as `usernameColumn`. + +```php +// Previously +class FooUserMergeListener extends AbstractUserMergeListener +{ + protected $databaseTables = [ + 'foo1_foo', + [ + 'name' => 'foo1_foo', + 'userID' => 'editorID', + 'username' => 'editor', + ], + [ + 'name' => 'foo1_bar', + 'username' => null, + 'ignore' => true, + ], + ]; +} + +// Since 6.3 +EventHandler::getInstance()->register( + \wcf\event\user\UsersMerging::class, + static function (\wcf\event\user\UsersMerging $event) { + $event->reassignUserColumn('foo1_foo', usernameColumn: 'username'); + $event->reassignUserColumn('foo1_foo', 'editorID', 'editor'); + $event->reassignUserColumn('foo1_bar', ignore: true); + } +); +``` + +The registered columns are updated after all event listeners have been executed. +Listeners that need to perform other changes can access the destination user and the merged users through the properties `destinationUser` and `mergedUsers` of the event. + +## User Renaming + +Users are renamed through the command `wcf\command\user\UpdateUser`, which fires the PSR-14 event `wcf\event\user\UserRenamed`. +`wcf\system\event\listener\AbstractUserActionRenameListener` is deprecated. +Event listeners for the `rename` event of `wcf\data\user\UserAction` are still executed, but should be migrated. + +Instead of extending `AbstractUserActionRenameListener`, a bootstrap script registers the database columns containing the username through `UserRenamed::updateUsernameColumn()`. + +```php +// Previously +class FooUserActionRenameListener extends AbstractUserActionRenameListener +{ + protected $databaseTables = [ + 'foo1_foo', + [ + 'name' => 'foo1_foo', + 'userID' => 'editorID', + 'username' => 'editor', + ], + ]; +} + +// Since 6.3 +EventHandler::getInstance()->register( + \wcf\event\user\UserRenamed::class, + static function (\wcf\event\user\UserRenamed $event) { + $event->updateUsernameColumn('foo1_foo'); + $event->updateUsernameColumn('foo1_foo', 'editorID', 'editor'); + } +); +``` + +The registered columns are updated after all event listeners have been executed. +Listeners that need to perform other changes can access the renamed user and the previous username through the properties `user` and `oldUsername` of the event. diff --git a/docs/tutorial/series/part_5.md b/docs/tutorial/series/part_5.md index 5ce0d6988..cee0b9d5b 100644 --- a/docs/tutorial/series/part_5.md +++ b/docs/tutorial/series/part_5.md @@ -33,6 +33,8 @@ The package will have the following file structure _excluding_ unchanged files f │ │ └── Controller │ │ └── Person.js │ └── lib +│ ├── bootstrap +│ │ └── com.woltlab.wcf.people.php │ ├── data │ │ └── person │ │ ├── Person.class.php @@ -216,22 +218,16 @@ filepath="tutorial/tutorial-series/part-5/files/lib/system/worker/PersonRebuildD As we store the name of the user who create a new piece of information and store their IP address, we have to add event listeners to properly handle the following scenarios: -1. If the user is renamed, the value of `username` stored with the person information has to be updated, which can be achieved by a simple event listener that only has to specify the name of relevant database table if `AbstractUserActionRenameListener` is extended: - - {jinja{ codebox( - title="files/lib/system/event/listener/PersonUserActionRenameListener.class.php", - language="php", - filepath="tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonUserActionRenameListener.class.php" - ) }} -2. If users are merged, all pieces of information need to be assigned to the target user of the merging. - Again, we only have to specify the name of relevant database table if `AbstractUserMergeListener` is extended: +1. If the user is renamed, the value of `username` stored with the person information has to be updated. + If users are merged, all pieces of information need to be assigned to the target user of the merging. + Both are handled by event listeners for the `UserRenamed` and `UsersMerging` events in the bootstrap script that only have to specify the name of the relevant database table and the column containing the username: {jinja{ codebox( - title="files/lib/system/event/listener/PersonUserMergeListener.class.php", + title="files/lib/bootstrap/com.woltlab.wcf.people.php", language="php", - filepath="tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonUserMergeListener.class.php" + filepath="tutorial/tutorial-series/part-5/files/lib/bootstrap/com.woltlab.wcf.people.php" ) }} -3. If the option to prune stored ip addresses after a certain period of time is enabled, we also have to prune them in the person information database table. +2. If the option to prune stored ip addresses after a certain period of time is enabled, we also have to prune them in the person information database table. Here we also only have to specify the name of the relevant database table and provide the mapping from the `ipAddress` column to the `time` column: {jinja{ codebox( @@ -239,7 +235,7 @@ As we store the name of the user who create a new piece of information and store language="php", filepath="tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonPruneIpAddressesCronjobListener.class.php" ) }} -4. The ip addresses in the person information database table also have to be considered for the user data export which can also be done with minimal effort by providing the name of the relevant database table: +3. The ip addresses in the person information database table also have to be considered for the user data export which can also be done with minimal effort by providing the name of the relevant database table: {jinja{ codebox( title="files/lib/system/event/listener/PersonUserExportGdprListener.class.php", @@ -247,7 +243,7 @@ As we store the name of the user who create a new piece of information and store filepath="tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonUserExportGdprListener.class.php" ) }} -Lastly, we present the updated `eventListener.xml` file with new entries for all of these event listeners: +Lastly, we present the updated `eventListener.xml` file with new entries for the remaining event listeners: {jinja{ codebox( title="eventListener.xml", diff --git a/snippets/tutorial/tutorial-series/part-5/eventListener.xml b/snippets/tutorial/tutorial-series/part-5/eventListener.xml index fbbc0a588..723656987 100644 --- a/snippets/tutorial/tutorial-series/part-5/eventListener.xml +++ b/snippets/tutorial/tutorial-series/part-5/eventListener.xml @@ -1,18 +1,6 @@ - - wcf\data\user\UserAction - rename - wcf\system\event\listener\PersonUserActionRenameListener - all - - - wcf\acp\form\UserMergeForm - save - wcf\system\event\listener\PersonUserMergeListener - admin - wcf\system\cronjob\PruneIpAddressesCronjob execute diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/bootstrap/com.woltlab.wcf.people.php b/snippets/tutorial/tutorial-series/part-5/files/lib/bootstrap/com.woltlab.wcf.people.php new file mode 100644 index 000000000..758339bb0 --- /dev/null +++ b/snippets/tutorial/tutorial-series/part-5/files/lib/bootstrap/com.woltlab.wcf.people.php @@ -0,0 +1,61 @@ +register( + ItemCollecting::class, + static function (ItemCollecting $event) { + if (!WCF::getSession()->getPermission('admin.content.canManagePeople')) { + return; + } + + $event->register(new AcpMenuItem( + 'wcf.acp.menu.link.person', + parentMenuItem: 'wcf.acp.menu.link.content' + )); + $event->register(new AcpMenuItem( + 'wcf.acp.menu.link.person.list', + parentMenuItem: 'wcf.acp.menu.link.person', + link: LinkHandler::getInstance()->getControllerLink(PersonListPage::class), + )); + $event->register(new AcpMenuItem( + 'wcf.acp.menu.link.person.add', + parentMenuItem: 'wcf.acp.menu.link.person.list', + link: LinkHandler::getInstance()->getControllerLink(PersonAddForm::class), + icon: FontAwesomeIcon::fromValues('plus') + )); + } + ); + + EventHandler::getInstance()->register( + ControllerCollecting::class, + static function (ControllerCollecting $event) { + $event->register(new \wcf\system\endpoint\controller\core\persons\DeletePerson()); + } + ); + + EventHandler::getInstance()->register( + UsersMerging::class, + static function (UsersMerging $event) { + $event->reassignUserColumn('wcf1_person_information', usernameColumn: 'username'); + } + ); + + EventHandler::getInstance()->register( + UserRenamed::class, + static function (UserRenamed $event) { + $event->updateUsernameColumn('wcf1_person_information'); + } + ); +}; diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonUserActionRenameListener.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonUserActionRenameListener.class.php deleted file mode 100644 index 73b0b09a3..000000000 --- a/snippets/tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonUserActionRenameListener.class.php +++ /dev/null @@ -1,20 +0,0 @@ - - */ -final class PersonUserActionRenameListener extends AbstractUserActionRenameListener -{ - /** - * @inheritDoc - */ - protected $databaseTables = [ - 'wcf{WCF_N}_person_information', - ]; -} diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonUserMergeListener.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonUserMergeListener.class.php deleted file mode 100644 index 0cf9c8338..000000000 --- a/snippets/tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonUserMergeListener.class.php +++ /dev/null @@ -1,20 +0,0 @@ - - */ -final class PersonUserMergeListener extends AbstractUserMergeListener -{ - /** - * @inheritDoc - */ - protected $databaseTables = [ - 'wcf{WCF_N}_person_information', - ]; -} diff --git a/snippets/tutorial/tutorial-series/part-6/eventListener.xml b/snippets/tutorial/tutorial-series/part-6/eventListener.xml index 61be2bac4..76d88b817 100644 --- a/snippets/tutorial/tutorial-series/part-6/eventListener.xml +++ b/snippets/tutorial/tutorial-series/part-6/eventListener.xml @@ -1,18 +1,6 @@ - - wcf\data\user\UserAction - rename - wcf\system\event\listener\PersonUserActionRenameListener - all - - - wcf\acp\form\UserMergeForm - save - wcf\system\event\listener\PersonUserMergeListener - admin - wcf\system\cronjob\PruneIpAddressesCronjob execute diff --git a/snippets/tutorial/tutorial-series/part-6/files/lib/bootstrap/com.woltlab.wcf.people.php b/snippets/tutorial/tutorial-series/part-6/files/lib/bootstrap/com.woltlab.wcf.people.php new file mode 100644 index 000000000..758339bb0 --- /dev/null +++ b/snippets/tutorial/tutorial-series/part-6/files/lib/bootstrap/com.woltlab.wcf.people.php @@ -0,0 +1,61 @@ +register( + ItemCollecting::class, + static function (ItemCollecting $event) { + if (!WCF::getSession()->getPermission('admin.content.canManagePeople')) { + return; + } + + $event->register(new AcpMenuItem( + 'wcf.acp.menu.link.person', + parentMenuItem: 'wcf.acp.menu.link.content' + )); + $event->register(new AcpMenuItem( + 'wcf.acp.menu.link.person.list', + parentMenuItem: 'wcf.acp.menu.link.person', + link: LinkHandler::getInstance()->getControllerLink(PersonListPage::class), + )); + $event->register(new AcpMenuItem( + 'wcf.acp.menu.link.person.add', + parentMenuItem: 'wcf.acp.menu.link.person.list', + link: LinkHandler::getInstance()->getControllerLink(PersonAddForm::class), + icon: FontAwesomeIcon::fromValues('plus') + )); + } + ); + + EventHandler::getInstance()->register( + ControllerCollecting::class, + static function (ControllerCollecting $event) { + $event->register(new \wcf\system\endpoint\controller\core\persons\DeletePerson()); + } + ); + + EventHandler::getInstance()->register( + UsersMerging::class, + static function (UsersMerging $event) { + $event->reassignUserColumn('wcf1_person_information', usernameColumn: 'username'); + } + ); + + EventHandler::getInstance()->register( + UserRenamed::class, + static function (UserRenamed $event) { + $event->updateUsernameColumn('wcf1_person_information'); + } + ); +}; diff --git a/snippets/tutorial/tutorial-series/part-6/files/lib/system/event/listener/PersonUserActionRenameListener.class.php b/snippets/tutorial/tutorial-series/part-6/files/lib/system/event/listener/PersonUserActionRenameListener.class.php deleted file mode 100644 index 73b0b09a3..000000000 --- a/snippets/tutorial/tutorial-series/part-6/files/lib/system/event/listener/PersonUserActionRenameListener.class.php +++ /dev/null @@ -1,20 +0,0 @@ - - */ -final class PersonUserActionRenameListener extends AbstractUserActionRenameListener -{ - /** - * @inheritDoc - */ - protected $databaseTables = [ - 'wcf{WCF_N}_person_information', - ]; -} diff --git a/snippets/tutorial/tutorial-series/part-6/files/lib/system/event/listener/PersonUserMergeListener.class.php b/snippets/tutorial/tutorial-series/part-6/files/lib/system/event/listener/PersonUserMergeListener.class.php deleted file mode 100644 index 0cf9c8338..000000000 --- a/snippets/tutorial/tutorial-series/part-6/files/lib/system/event/listener/PersonUserMergeListener.class.php +++ /dev/null @@ -1,20 +0,0 @@ - - */ -final class PersonUserMergeListener extends AbstractUserMergeListener -{ - /** - * @inheritDoc - */ - protected $databaseTables = [ - 'wcf{WCF_N}_person_information', - ]; -} From 3b7e874a97c601bfad922e5f82142027772b85b5 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Mon, 12 Oct 2026 21:00:24 +0200 Subject: [PATCH 2/2] Document user management overhaul --- docs/migration/wsc62/deprecations_removals.md | 83 ++++ docs/migration/wsc62/php.md | 414 ++++++++++++++++++ docs/package/pip/user-option.md | 151 ++++++- docs/php/api/event_list.md | 3 - docs/php/api/events.md | 10 + docs/php/api/form_builder/form_fields.md | 22 +- docs/php/api/form_builder/form_options.md | 38 +- docs/php/api/grid_views.md | 32 ++ docs/php/api/interactions.md | 12 + docs/php/api/rpc_api.md | 14 + docs/php/api/rpc_api_endpoints.md | 23 + docs/php/database-object-builder.md | 2 + snippets/package/pip/userOption.xml | 35 ++ 13 files changed, 823 insertions(+), 16 deletions(-) create mode 100644 snippets/package/pip/userOption.xml diff --git a/docs/migration/wsc62/deprecations_removals.md b/docs/migration/wsc62/deprecations_removals.md index efc1782d9..f4249ab7d 100644 --- a/docs/migration/wsc62/deprecations_removals.md +++ b/docs/migration/wsc62/deprecations_removals.md @@ -130,6 +130,19 @@ With version 6.3, we have deprecated certain components and removed several othe - `wcf\system\event\listener\AbstractUserMergeListener` - `wcf\system\event\listener\AbstractUserActionRenameListener` - `wcf\data\user\UserEditor` +- `wcf\data\user\UserAction` +- `wcf\data\user\UserProfileAction` +- `wcf\data\user\option\UserOptionAction` +- `wcf\data\user\option\UserOptionEditor` +- `wcf\data\user\option\ViewableUserOption` +- `wcf\data\user\option\category\UserOptionCategoryAction` +- `wcf\data\user\option\category\UserOptionCategoryEditor` +- `wcf\system\condition\UserMultifactorCondition` +- `wcf\system\condition\UserUserIDCondition` +- `wcf\system\option\ISearchableConditionUserOption` +- `wcf\system\option\ISearchableUserOption` +- `wcf\system\option\user\IUserOptionOutput` +- `wcf\system\worker\SendNewPasswordWorker` #### Methods @@ -363,6 +376,11 @@ With version 6.3, we have deprecated certain components and removed several othe - `calendar\data\event\date\EventDateEditor::rebuildParticipants()` - `wcf\data\notice\Notice::getConditions()` - `wcf\data\user\group\assignment\UserGroupAssignment::getConditions()` +- `wcf\data\user\profile\menu\item\UserProfileMenuItemAction::validateGetContent()` +- `wcf\data\user\profile\menu\item\UserProfileMenuItemAction::getContent()` +- `wcf\data\user\UserProfile::setFileAvatar()` +- `wcf\system\view\user\profile\UserProfileHeaderView::isInAccessibleGroup()` +- `wcf\util\UserRegistrationUtil::getRandomFieldName()` #### Properties @@ -375,6 +393,7 @@ With version 6.3, we have deprecated certain components and removed several othe #### Object Type Definitions - `com.woltlab.wcf.sitemap.object`, use the event `wcf\event\sitemap\SitemapObjectCollecting` instead +- `com.woltlab.wcf.condition.userSearch`, use the event `wcf\event\gridView\admin\UserGridViewInitialized` to add filters to the user list instead ### JavaScript @@ -382,6 +401,7 @@ With version 6.3, we have deprecated certain components and removed several othe - `WoltLabSuite/Core/Ui/Sortable/List` - `WoltLabSuite/Core/Component/GoogleMaps/MarkerLoader::setup()` - `WoltLabSuite/Core/Ui/User/Sortable/ObjectWatch` +- `WoltLabSuite/Core/Acp/Ui/User/Action/Handler/SendNewPassword` ## Removals @@ -453,6 +473,33 @@ With version 6.3, we have deprecated certain components and removed several othe - `wcf\data\acp\session\ACPSessionList` - `calendar\data\user\FollowingBirthdayAction` - `calendar\data\category\CalendarCategoryAction` +- `wcf\acp\action\UserQuickSearchAction` +- `wcf\acp\form\UserAssignToGroupForm` +- `wcf\acp\form\UserContentRevertChangesForm` +- `wcf\acp\form\UserEmailAddressExportForm` +- `wcf\acp\form\UserOptionListForm` +- `wcf\acp\form\UserSearchForm` +- `wcf\action\EmailValidationAction` +- `wcf\action\UsernameValidationAction` +- `wcf\data\user\UserContentAction` +- `wcf\data\user\UserRegistrationAction` +- `wcf\form\UserSearchForm` +- `wcf\system\clipboard\action\UserClipboardAction` +- `wcf\system\clipboard\action\UserContentClipboardAction` +- `wcf\system\condition\UserOptionsCondition` +- `wcf\system\listView\user\UserSearchResultsListView` +- `wcf\system\option\user\BirthdayUserOptionOutput` +- `wcf\system\option\user\DateUserOptionOutput` +- `wcf\system\option\user\FacebookUserOptionOutput` +- `wcf\system\option\user\FloatUserOptionOutput` +- `wcf\system\option\user\ImageUserOptionOutput` +- `wcf\system\option\user\LabeledUrlUserOptionOutput` +- `wcf\system\option\user\MessageUserOptionOutput` +- `wcf\system\option\user\NewlineToBreakUserOptionOutput` +- `wcf\system\option\user\SelectOptionsUserOptionOutput` +- `wcf\system\option\user\TwitterUserOptionOutput` +- `wcf\system\option\user\URLUserOptionOutput` +- `wcf\system\option\user\UserOptionHandler` #### Methods @@ -562,6 +609,18 @@ With version 6.3, we have deprecated certain components and removed several othe - `calendar\data\event\date\participation\EventDateParticipationAction::invite()` - `wcf\data\package\update\PackageUpdateAction::validateSearchForUpdates()` - `wcf\data\package\update\PackageUpdateAction::searchForUpdates()` +- `wcf\data\user\UserAction::validatePrepareRemoveContent()` +- `wcf\data\user\UserAction::prepareRemoveContent()` +- `wcf\data\user\UserProfileAction::validateBeginEdit()` +- `wcf\data\user\UserProfileAction::beginEdit()` +- `wcf\data\user\UserProfileAction::validateSave()` +- `wcf\data\user\UserProfileAction::save()` +- `wcf\data\user\UserProfileAction::getOptionHandler()` +- `wcf\data\user\option\UserOption::setUser()` +- `wcf\data\user\option\UserOption::modifyEditableOption()` +- `wcf\data\user\option\UserOption::modifyVisibleOption()` +- `wcf\data\user\option\UserOption::setRequired()` +- `wcf\data\user\option\ViewableUserOption::getOutputObject()` #### Properties @@ -578,6 +637,10 @@ With version 6.3, we have deprecated certain components and removed several othe - `wcf\data\comment\ViewableComment::$userProfile` - `wcf\data\comment\response\StructuredCommentResponse::$userProfile` - `wcf\data\comment\response\ViewableCommentResponse::$userProfile` +- `wcf\data\user\option\UserOption::$optionValue` +- `wcf\data\user\option\UserOption::$user` +- `wcf\data\user\option\ViewableUserOption::$outputObjects` +- `wcf\page\MembersListPage::$searchID` ### JavaScript @@ -631,3 +694,23 @@ With version 6.3, we have deprecated certain components and removed several othe - `WCF.ACP.Package.Update.Search` - `WoltLabSuite/Calendar/Ui/Category/MarkAllAsRead` - `WCF.ACP.Ad.LocationHandler` +- `WoltLabSuite/Core/Acp/Component/Tag/SynonymsHandler` +- `WoltLabSuite/Core/Acp/Controller/User/List` +- `WoltLabSuite/Core/Acp/Ui/User/Action/Abstract` +- `WoltLabSuite/Core/Acp/Ui/User/Action/BanAction` +- `WoltLabSuite/Core/Acp/Ui/User/Action/DeleteAction` +- `WoltLabSuite/Core/Acp/Ui/User/Action/DisableAction` +- `WoltLabSuite/Core/Acp/Ui/User/Action/Handler/Ban` +- `WoltLabSuite/Core/Acp/Ui/User/Action/Handler/Ban/Dialog` +- `WoltLabSuite/Core/Acp/Ui/User/Action/Handler/Delete` +- `WoltLabSuite/Core/Acp/Ui/User/Action/SendNewPasswordAction` +- `WoltLabSuite/Core/Acp/Ui/User/Action/ToggleConfirmEmailAction` +- `WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Clipboard` +- `WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Handler` +- `WoltLabSuite/Core/Acp/Ui/User/Editor` +- `WoltLabSuite/Core/Ui/User/Editor` +- `WCF.User.Profile.Editor` +- `WCF.User.Registration` +- `WCF.User.Registration.Validation` +- `WCF.User.Registration.Validation.EmailAddress` +- `WCF.User.Registration.Validation.Username` diff --git a/docs/migration/wsc62/php.md b/docs/migration/wsc62/php.md index 0d2cd08e4..d74f0fe09 100644 --- a/docs/migration/wsc62/php.md +++ b/docs/migration/wsc62/php.md @@ -469,3 +469,417 @@ EventHandler::getInstance()->register( The registered columns are updated after all event listeners have been executed. Listeners that need to perform other changes can access the renamed user and the previous username through the properties `user` and `oldUsername` of the event. + +## Creating and Updating Users + +`wcf\data\user\UserAction` and `wcf\data\user\UserEditor` are deprecated, users are created and modified through the [database object builder](../../php/database-object-builder.md) `wcf\data\user\UserBuilder` and the commands in the `wcf\command\user` namespace. +The commands take care of all side effects, such as updating the user rank, the user online marking and the automatic user group assignments. + +```php +// Previously +$action = new UserAction([], 'create', [ + 'data' => [ + 'username' => 'Foo', + 'email' => 'foo@example.com', + 'password' => $password, + ], + 'groups' => [$groupID], + 'options' => [ + User::getUserOptionID('location') => 'Berlin', + ], +]); +$user = $action->executeAction()['returnValues']; + +// Since 6.3 +$user = (new CreateUser( + UserBuilder::forCreate() + ->setUsername('Foo') + ->setEmail('foo@example.com') + ->setPassword($password) + ->setRegistrationDate(\TIME_NOW) + ->setGroupIDs([ + ...UserGroup::getGroupIDsByType([UserGroup::EVERYONE, UserGroup::USERS]), + $groupID, + ]) + ->setUserOption(User::getUserOptionID('location'), 'Berlin') +))(); +``` + +```php +// Previously +$action = new UserAction([$user], 'update', [ + 'data' => [ + 'userTitle' => 'Bar', + 'fooColumn' => 1, + ], +]); +$action->executeAction(); + +// Since 6.3 +$user = (new UpdateUser( + UserBuilder::forUpdate($user) + ->setUserTitle('Bar') + ->setCustomProperty('fooColumn', 1) +))(); +``` + +The user options, the user groups and the content languages are stored in separate tables that are written after the user itself. +The object returned by `UserBuilder::create()` and `UserBuilder::update()` is therefore loaded before these tables have been written, always use the `CreateUser` and `UpdateUser` commands which return an up-to-date object. +`UserBuilder::delete()` only removes the database row, use the `DeleteUser` command to delete a user including the associated data. + +The methods of `UserAction` and `UserProfileAction` have been replaced as follows: + +| Method | Replacement | +| ------ | ----------- | +| `UserAction::create()` | `wcf\command\user\CreateUser` | +| `UserAction::update()` | `wcf\command\user\UpdateUser` | +| `UserAction::delete()` | `wcf\command\user\DeleteUser` | +| `UserAction::addToGroups()` | `wcf\command\user\AddUserToGroups` | +| `UserAction::removeFromGroups()` | `wcf\command\user\RemoveUserFromGroups` | +| `UserAction::ban()` / `unban()` | `wcf\command\user\BanUser` / `UnbanUser` | +| `UserAction::enable()` / `disable()` | `wcf\command\user\EnableUser` / `DisableUser` | +| `UserAction::confirmEmail()` / `unconfirmEmail()` | `wcf\command\user\ConfirmUserEmail` / `UnconfirmUserEmail` | +| `UserAction::enableAvatar()` / `disableAvatar()` | `wcf\command\user\EnableUserAvatar` / `DisableUserAvatar` | +| `UserAction::enableCoverPhoto()` / `disableCoverPhoto()` | `wcf\command\user\EnableUserCoverPhoto` / `DisableUserCoverPhoto` | +| `UserAction::enableSignature()` / `disableSignature()` | `wcf\command\user\EnableUserSignature` / `DisableUserSignature` | +| `UserAction::resendActivationMail()` | `wcf\command\user\ResendActivationMail` | +| `UserAction::saveUserConsent()` | RPC endpoint `wcf\system\endpoint\controller\core\users\SaveUserConsent` | +| `UserAction::devtoolsSetLanguage()` | RPC endpoint `wcf\system\endpoint\controller\core\devtools\SetLanguage` | +| `UserProfileAction::getMessagePreview()` | RPC endpoint `wcf\system\endpoint\controller\core\users\RenderSignaturePreview` | +| `UserProfileAction::getDetailedActivityPointList()` | RPC endpoint `wcf\system\endpoint\controller\core\users\GetUserActivityPointList` | +| `UserProfileAction::updateUserRank()` | `wcf\command\user\RefreshUserRanks` | +| `UserProfileAction::updateUserOnlineMarking()` | `wcf\command\user\RefreshUserOnlineMarkings` | +| `UserProfileAction::updateSpecialTrophies()` | `wcf\command\user\SetSpecialTrophies` | +| `SendNewPasswordWorker` | `wcf\command\user\SendNewPassword` | + +Additionally, the commands `DisableUserMultifactor`, `DisconnectThirdPartyLogin` and `MergeUsers` have been added. + +!!! warning "WoltLab Suite no longer uses `UserAction` to create, update or delete users. Event listeners for the `initializeAction` and `finalizeAction` events of `UserAction` are no longer executed for these operations and must be migrated to the PSR-14 events below. Conversely, the deprecated methods `UserAction::create()`, `UserAction::update()` and `UserAction::delete()` do not fire the new events." + +### Events + +The commands fire the following PSR-14 events: + +| Event | Description | +| ----- | ----------- | +| `wcf\event\user\UserCreated` | Fired after a user has been created, provides the user and the builder. | +| `wcf\event\user\UserUpdated` | Fired after a user has been updated, provides the updated user and the builder. | +| `wcf\event\user\UserDeleted` | Fired after a user has been deleted. | + +All commands that modify a user, such as `BanUser` or `AddUserToGroups`, use `UpdateUser` internally and therefore fire `UserUpdated`. +The builder exposes the changes, `getUserOptions()`, `getGroupIDs()` and `getLanguageIDs()` return `null` if the respective data has not been changed. +The user prior to the update is available through `$event->builder->getObject()`. + +```php +EventHandler::getInstance()->register( + \wcf\event\user\UserUpdated::class, + static function (\wcf\event\user\UserUpdated $event) { + if ($event->builder->getGroupIDs() === null) { + return; + } + + // The user groups have been changed. + } +); +``` + +### Legacy Events of `UserProfile` + +The legacy events `getAvatar` and `isAccessible` of `wcf\data\user\UserProfile` have been removed in favor of PSR-14 events. + +```php +// Previously +final class FooUserProfileListener implements IParameterizedEventListener +{ + public function execute($eventObj, $className, $eventName, array &$parameters) + { + if ($eventName === 'getAvatar') { + $parameters['avatar'] = new FooAvatar($eventObj->getDecoratedObject()); + } elseif ($eventName === 'isAccessible' && $parameters['name'] === 'fooPrivacySetting') { + $parameters['result'] = FooPrivacy::isAccessible($eventObj->userID, $parameters['userID']); + } + } +} + +// Since 6.3 +EventHandler::getInstance()->register( + \wcf\event\user\UserAvatarLoading::class, + static function (\wcf\event\user\UserAvatarLoading $event) { + $event->setAvatar(new FooAvatar($event->user)); + } +); + +EventHandler::getInstance()->register( + \wcf\event\user\UserPrivacySettingChecking::class, + static function (\wcf\event\user\UserPrivacySettingChecking $event) { + if ($event->name === 'fooPrivacySetting') { + $event->setAccessible(FooPrivacy::isAccessible($event->user->userID, $event->viewerUserID)); + } + } +); +``` + +## Permissions and Profile Data of Users + +The permission checks for the administration of users are available as methods of `wcf\data\user\User`, which are used by the interactions, the RPC endpoints and the forms: + +- `isManageable(bool $allowSelf = false)` returns `true` if all user groups of the user are accessible to the active user and, unless `$allowSelf` is set, the user is not the active user. +- `canEnable()`, `canConfirmEmail()`, `canBan()`, `canDisableAvatar()`, `canDisableCoverPhoto()`, `canDisableSignature()`, `canSendNewPassword()`, `canDisconnectThirdPartyLogin()`, `canDisableMultifactor()`, `canResendActivationMail()`, `canDelete()`, `canExportGdpr()` and `canMail()` check the permission for the respective action. + +The result of `canConfirmEmail()` can be modified through the PSR-14 event `wcf\event\user\UserEmailConfirmationChecking`. +Do not confuse this method with the existing `canEmailConfirm()`, which checks whether the user can confirm the email address themselves. + +The avatar, the rank, the user title, the online status and the privacy settings are also available on `User` through `getAvatar()`, `getRank()`, `getUserTitle()`, `isOnline()` and `isAccessible()`, it is no longer necessary to create a `UserProfile` for these. +The data required for these methods is loaded for all users of the same collection at once (`wcf\data\user\UserCollection`). +The corresponding methods of `UserProfile` remain available. +The constants `UserProfile::ACCESS_*` are aliases of the new constants `User::ACCESS_*`. + +## ACP User Add and Edit Form + +`wcf\acp\form\UserAddForm` and `wcf\acp\form\UserEditForm` have been migrated to the form builder and are based on `AbstractDatabaseObjectBuilderForm` using `UserBuilder`. +All properties representing the form values and all template events of the `userAdd` template except for `contentHeaderNavigation` have been removed. +The options to ban a user, to disable the avatar, cover photo and signature of a user, to disconnect a third party login and to disable the multi-factor authentication have been moved to the interactions on the edit page. + +Additional form fields can be added through the `createForm` event of `UserAddForm`, which is inherited by `UserEditForm`. +The values are written into the builder using `saveValueCallback()` and read from the user using `loadValueCallback()`. + +```php +final class FooUserAddFormListener implements IParameterizedEventListener +{ + public function execute($eventObj, $className, $eventName, array &$parameters) + { + $eventObj->form->getNodeById('general')->appendChild( + BooleanFormField::create('fooEnabled') + ->label('wcf.acp.user.fooEnabled') + ->saveValueCallback(static function (UserBuilder $builder, BooleanFormField $field) { + $builder->setCustomProperty('fooEnabled', $field->getSaveValue()); + }) + ->loadValueCallback(static function (User $user, BooleanFormField $field) { + $field->value($user->fooEnabled); + }) + ); + } +} +``` + +```xml + + admin + wcf\acp\form\UserAddForm + createForm + wcf\system\event\listener\FooUserAddFormListener + 1 + +``` + +The form consists of the tab menu `userTabs` with the tabs `generalTab`, `profileTab`, `settingsTab` and `signatureTab`. +The general data is contained in the form container `general`. +Data that is stored in other tables can be saved using the `UserCreated` and `UserUpdated` events. + +`wcf\form\RegisterForm` and `wcf\form\SettingsForm` have also been migrated to the form builder and are now final. +The template events `generalFields`, `honeyPotFields` and `sections` of the `register` template and `languageFields`, `styleFields`, `trophyFields` and `sections` of the `settings` template have been removed. + +## User List + +The user list in the ACP (`wcf\acp\page\UserListPage`) has been migrated to a [grid view](../../php/api/grid_views.md) (`wcf\system\gridView\admin\UserGridView`) and is now final. +The bulk actions of the user list are provided as [bulk interactions](../../php/api/interactions.md#bulk-interactions) instead of the clipboard. +The grid view, the interactions and the bulk interactions can be extended through the events `wcf\event\gridView\admin\UserGridViewInitialized`, `wcf\event\interaction\admin\UserInteractionCollecting` and `wcf\event\interaction\bulk\admin\UserBulkInteractionCollecting`. + +```php +EventHandler::getInstance()->register( + \wcf\event\gridView\admin\UserGridViewInitialized::class, + static function (\wcf\event\gridView\admin\UserGridViewInitialized $event) { + $event->gridView->addColumnAfter( + GridViewColumn::for('fooCount') + ->label('wcf.acp.user.fooCount') + ->renderer(new NumberColumnRenderer()) + ->sortable() + ->hidden(), + 'lastActivityTime' + ); + $event->gridView->addAvailableFilters([ + new BooleanFilter('fooEnabled', 'wcf.acp.user.fooEnabled'), + ]); + } +); + +EventHandler::getInstance()->register( + \wcf\event\interaction\bulk\admin\UserBulkInteractionCollecting::class, + static function (\wcf\event\interaction\bulk\admin\UserBulkInteractionCollecting $event) { + $event->provider->addInteraction( + new BulkLinkInteraction('foo', FooForm::class, 'wcf.acp.user.foo') + ); + } +); +``` + +The user search in the ACP (`wcf\acp\form\UserSearchForm`) and in the frontend (`wcf\form\UserSearchForm`) has been removed in favor of the filters of the user list and of the members list. +Links to a filtered user list can be generated using the `filters` parameter, e.g. `UserList?filters[banned]=1`. +The object type definition `com.woltlab.wcf.condition.userSearch` is deprecated and its object types are no longer used, use the `UserGridViewInitialized` event to add custom filters instead. + +The forms to assign users to groups, to export email addresses, to merge users and to revert content changes have been replaced by dialogs of the bulk interactions. + +## User Profile Header in the ACP + +The edit page of a user in the ACP shows the profile header of the user (`wcf\system\view\user\profile\AdminUserProfileHeaderView`), which provides the interactions of the user. +The header fires the existing events `wcf\event\user\profile\UserProfileStatItemCollecting` and `wcf\event\user\profile\UserProfileHeaderSearchContentLinkCollecting`, which means that their listeners are also executed in the ACP. +Links of stat items must be absolute, e.g. `$user->getLink() . '#foo'` instead of `#foo`. + +## User Options + +The user options have been migrated to [form options](../../php/api/form_builder/form_options.md), which provide the input field, the formatted output, the database column and the filter of an option. +`wcf\data\user\option\UserOption` no longer extends `wcf\data\option\Option`. + +### Package Installation Plugin + +The value of `` in the [userOption PIP](../../package/pip/user-option.md) is the id of a form option. +The legacy option types `aboutMe`, `message`, `multiSelect`, `URL` and `labeledUrl` are converted automatically, the fields `outputclass`, `enableoptions`, `contentpattern` and `messageObjectType` as well as arbitrary additional fields are ignored. +Values of `checkboxes` options, including the default value, are separated by commas instead of newlines. + +The new option type `socialMedia` replaces `labeledUrl` and plain text options that contain the profile name on a social media platform: + +```xml + + + + + +``` + +Existing user options are converted during the upgrade to 6.3. + +### Custom Option Types + +Custom option types (`wcf\system\option\IOptionType`) and output classes (`wcf\system\option\user\IUserOptionOutput`) are no longer used for user options, options with an unknown type are treated as `text` options. +Custom types must be implemented as a form option (`wcf\system\form\option\IFormOption`) and registered through the `wcf\event\form\option\FormOptionCollecting` event. +The column of the option in `wcf1_user_option_value` is created based on `IFormOption::getDatabaseTableColumn()`. + +The new method `IFormOption::matchesFilterValue()` must be implemented by form options that do not extend `AbstractFormOption`. + +### Formatted Values + +The output classes have been replaced by `UserOption::getFormattedValue()`, `wcf\data\user\option\ViewableUserOption` is deprecated. + +```php +// Previously +$option = new ViewableUserOption($userOption); +$option->setOptionValue($userProfile->getDecoratedObject()); +$output = $option->optionValue; + +// Since 6.3 +$output = $userProfile->getFormattedUserOption('foo'); +// or +$output = $userOption->getFormattedValue($user->getUserOption('foo'), $user); +``` + +`UserProfile::getFormattedUserOption()` returns an empty string if the option has no value or is not visible to the active user, while `UserOption::getFormattedValue()` does not check the visibility. + +### Visibility and Editability + +`UserOption::setUser()` has been removed, the user is passed to `isVisible()` and `isEditable()` instead. +This avoids side effects caused by modifying the shared option objects of the cache. + +```php +// Previously +$userOption->setUser($user); +if ($userOption->isVisible()) { + // … +} + +// Since 6.3 +if ($userOption->isVisible($user)) { + // … +} +``` + +### Forms and Lists + +`wcf\system\option\user\UserOptionHandler`, `wcf\acp\form\UserOptionListForm` and the templates `userOptionFieldList` and `userProfileOptionFieldList` have been removed. +The following traits provide the building blocks to integrate user options: + +- `wcf\system\option\user\TUserOptionsByCategory` returns the options of a category tree grouped by the subcategories (`getUserOptionsByCategory()`) and the title of a category (`getUserOptionCategoryTitle()`). +- `wcf\system\form\option\user\TUserOptionFormFields` creates form containers and form fields for user options in forms based on `AbstractDatabaseObjectBuilderForm` using `UserBuilder`. +- `wcf\system\option\user\TJoinUserOptionValues` joins the values of the user options to a database object list of users. + +```php +final class FooForm extends AbstractDatabaseObjectBuilderForm +{ + use TUserOptionFormFields; + + /** + * @var array + */ + private array $options; + + #[\Override] + public function readParameters(): void + { + parent::readParameters(); + + $this->formObject = new User(WCF::getUser()->userID); + $this->options = $this->getUserOptionsByCategory( + 'settings.foo', + static fn(UserOption $option) => $option->validateOptions() + && $option->validatePermissions() + && $option->isEditable() + ); + } + + #[\Override] + protected function createForm(): void + { + parent::createForm(); + + $this->form->appendChildren($this->getUserOptionFormContainers($this->options)); + } + + #[\Override] + protected function getDatabaseObjectBuilder(): UserBuilder + { + return UserBuilder::forUpdate($this->formObject); + } + + #[\Override] + protected function getCommand(DatabaseObjectBuilder $builder): callable + { + return new UpdateUser($builder); + } +} +``` + +### Builders and Commands + +`UserOptionAction` and `UserOptionEditor` are deprecated in favor of `wcf\data\user\option\UserOptionBuilder` and the commands `CreateUserOption`, `UpdateUserOption` and `DeleteOption` in the `wcf\command\user\option` namespace. +`UserOptionCategoryAction` and `UserOptionCategoryEditor` are deprecated in favor of `wcf\data\user\option\category\UserOptionCategoryBuilder` and the commands `CreateUserOptionCategory` and `UpdateUserOptionCategory`, which fire the events `UserOptionCategoryCreated` and `UserOptionCategoryUpdated`. + +The titles of user option categories are stored in the localized storage, use `UserOptionCategory::getTitle()` instead of the language item `wcf.user.option.category.{categoryName}`. +The descriptions of user option categories are no longer displayed. + +### Filters + +User options of the `profile` category tree are automatically available as filters, which replace the user search and the `com.woltlab.wcf.userOptions` conditions: + +- the filters of the user list in the ACP, which also provides a hidden column for each option, +- the filters of the members list for options that are `searchable` and visible to other users than the owner, +- the object filters of ads, notices and automatic user group assignments (`wcf\system\object\filter\user\UserOptionObjectFilter`). + +No additional code is required, the filter is provided by the form option of the user option. + +## Grid View Column Visibility + +Grid views can allow users to select the visible columns, see [Column Visibility](../../php/api/grid_views.md#column-visibility). +`AbstractGridViewPage` reads the selected columns from the `columns` parameter into the new property `$columns`, which may conflict with an existing property of the same name in a subclass. diff --git a/docs/package/pip/user-option.md b/docs/package/pip/user-option.md index 120cf0804..4c47985f3 100644 --- a/docs/package/pip/user-option.md +++ b/docs/package/pip/user-option.md @@ -1,27 +1,129 @@ # User Option Package Installation Plugin Registers new user options (profile fields / user settings). -The behaviour of this package installation plugin closely follows the [option](option.md) PIP. + +The input field and the output of a user option are provided by [form options](../../php/api/form_builder/form_options.md), the same infrastructure that is used for contact form options and other custom fields. +User options of the `profile` category tree are also automatically available as filters in the user list of the ACP, in the members list (if they are `searchable`) and in object filters, e.g. for ads, notices and automatic user group assignments. ## Category Components -The category definition works exactly like the option PIP. +Each category is described as an `` element with the mandatory attribute `name`. + +### `` + +Optional + +The category’s parent category. + +### `` + +Optional + +Specifies the order of this category within the parent category. + +### `` + +Optional + +The options element can contain a comma-separated list of options of which at least one needs to be enabled for the category to be shown. + +### `` + +Optional + +The permissions element can contain a comma-separated list of permissions of which the active user needs to have at least one for the category to be shown. ## Option Components -The fields `hidden`, `supporti18n` and `requirei18n` do not apply. -The following extra fields are defined: +Each option is described as an `