From a7ba6e187dd32a5fe5dd9ca04f4635b9978470c9 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Thu, 1 Oct 2026 12:51:29 +0200
Subject: [PATCH 001/115] Migrate SettingsForm to form builder, backed by form
options for user options
---
com.woltlab.wcf/package.xml | 1 +
com.woltlab.wcf/templates/settings.tpl | 139 +----
.../update_com.woltlab.wcf_6.3_step1.php | 1 +
...oltlab.wcf_6.3_userOptionConfiguration.php | 40 ++
.../lib/data/user/option/UserOption.class.php | 62 +++
.../user/option/UserOptionEditor.class.php | 55 ++
.../files/lib/form/SettingsForm.class.php | 482 +++++++++++-------
.../form/option/FormOptionHandler.class.php | 1 +
.../form/option/TimezoneFormOption.class.php | 52 ++
wcfsetup/install/lang/de.xml | 1 +
wcfsetup/install/lang/en.xml | 1 +
wcfsetup/setup/db/install_com.woltlab.wcf.php | 1 +
12 files changed, 527 insertions(+), 309 deletions(-)
create mode 100644 wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userOptionConfiguration.php
create mode 100644 wcfsetup/install/files/lib/system/form/option/TimezoneFormOption.class.php
diff --git a/com.woltlab.wcf/package.xml b/com.woltlab.wcf/package.xml
index d4f63a11a66..223262b33c4 100644
--- a/com.woltlab.wcf/package.xml
+++ b/com.woltlab.wcf/package.xml
@@ -88,6 +88,7 @@
acp/update_com.woltlab.wcf_6.3_paidSubscriptionL10n.php
acp/update_com.woltlab.wcf_6.3_userOptionL10n.php
acp/update_com.woltlab.wcf_6.3_captchaQuestionL10n.php
+ acp/update_com.woltlab.wcf_6.3_userOptionConfiguration.php
acp/database/update_com.woltlab.wcf_6.3_step3.php
The following step requires the new files and must run before the `cronjob` PIP, which precedes the `objectType` PIP:
diff --git a/com.woltlab.wcf/templates/settings.tpl b/com.woltlab.wcf/templates/settings.tpl
index ccb855d79bb..e63bd0d4a98 100644
--- a/com.woltlab.wcf/templates/settings.tpl
+++ b/com.woltlab.wcf/templates/settings.tpl
@@ -6,143 +6,6 @@
{include file='header' __disableAds=true __sidebarLeftHasMenu=true}
-{include file='shared_formError'}
-
-{if $success|isset}
- {lang}wcf.global.success.edit{/lang}
-{/if}
-
-
+{unsafe:$form->getHtml()}
{include file='footer' __disableAds=true}
diff --git a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php
index c089e60b350..1c8344f159a 100644
--- a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php
+++ b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php
@@ -67,6 +67,7 @@
PartialDatabaseTable::create('wcf1_user_option')
->columns([
DefaultFalseBooleanDatabaseTableColumn::create('showOnUserCard'),
+ MediumtextDatabaseTableColumn::create('configuration'),
VarcharDatabaseTableColumn::create('l10nIdentifier')
->length(255),
]),
diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userOptionConfiguration.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userOptionConfiguration.php
new file mode 100644
index 00000000000..528839c9283
--- /dev/null
+++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userOptionConfiguration.php
@@ -0,0 +1,40 @@
+
+ */
+
+use wcf\data\user\option\UserOptionEditor;
+use wcf\system\WCF;
+
+$sql = "SELECT optionID, selectOptions, required
+ FROM wcf1_user_option";
+$statement = WCF::getDB()->prepare($sql);
+$statement->execute();
+$configurations = [];
+while ($row = $statement->fetchArray()) {
+ $configurations[$row['optionID']] = UserOptionEditor::buildConfiguration(
+ $row['selectOptions'] ?? '',
+ (bool)$row['required']
+ );
+}
+
+$sql = "UPDATE wcf1_user_option
+ SET configuration = ?
+ WHERE optionID = ?";
+$statement = WCF::getDB()->prepare($sql);
+
+WCF::getDB()->beginTransaction();
+foreach ($configurations as $optionID => $configuration) {
+ $statement->execute([
+ $configuration,
+ $optionID,
+ ]);
+}
+WCF::getDB()->commitTransaction();
+
+UserOptionEditor::resetCache();
diff --git a/wcfsetup/install/files/lib/data/user/option/UserOption.class.php b/wcfsetup/install/files/lib/data/user/option/UserOption.class.php
index c27d363806a..477fd9bb93e 100644
--- a/wcfsetup/install/files/lib/data/user/option/UserOption.class.php
+++ b/wcfsetup/install/files/lib/data/user/option/UserOption.class.php
@@ -7,6 +7,8 @@
use wcf\data\user\User;
use wcf\system\l10n\L10nDefinition;
use wcf\system\l10n\L10nStorage;
+use wcf\system\form\option\FormOptionHandler;
+use wcf\system\form\option\IFormOption;
use wcf\system\WCF;
/**
@@ -30,6 +32,7 @@
* @property-read ?string $l10nIdentifier base name of the language variable the localized title/description are derived from, `null` for options created by an administrator
*
* @phpstan-import-type L10nValue from L10nStorage
+ * @property-read ?string $configuration JSON-encoded configuration information depending on the option type
*/
class UserOption extends Option implements ITitledObject
{
@@ -105,6 +108,24 @@ class UserOption extends Option implements ITitledObject
*/
const EDITABILITY_OWNER_DURING_REGISTRATION_AND_ADMINISTRATOR = 6;
+ /**
+ * Maps the legacy option types to the ids of the form options with a compatible value format.
+ * Option types without a compatible form option fall back to the text form option.
+ *
+ * @var array
+ */
+ private const FORM_OPTION_TYPES = [
+ 'boolean' => 'boolean',
+ 'float' => 'float',
+ 'integer' => 'integer',
+ 'radioButton' => 'radioButton',
+ 'select' => 'select',
+ 'text' => 'text',
+ 'textarea' => 'textarea',
+ 'timezone' => 'timezone',
+ 'URL' => 'url',
+ ];
+
/**
* @inheritDoc
*/
@@ -191,6 +212,47 @@ public static function getL10nDefinition(): L10nDefinition
);
}
+ /**
+ * Returns the identifier for the form field.
+ *
+ * @since 6.3
+ */
+ public function getIdentifier(): string
+ {
+ return "userOption{$this->optionID}";
+ }
+
+ /**
+ * @since 6.3
+ */
+ public function getFormOption(): IFormOption
+ {
+ $formOption = null;
+ if (isset(self::FORM_OPTION_TYPES[$this->optionType])) {
+ $formOption = FormOptionHandler::getInstance()->getOption(self::FORM_OPTION_TYPES[$this->optionType]);
+ }
+
+ if ($formOption === null) {
+ $formOption = FormOptionHandler::getInstance()->getOption('text');
+ \assert($formOption !== null);
+ }
+
+ return $formOption;
+ }
+
+ /**
+ * @return array
+ * @since 6.3
+ */
+ public function getConfiguration(): array
+ {
+ return ($this->configuration ?? '') !== '' ? \json_decode(
+ $this->configuration,
+ true,
+ flags: \JSON_THROW_ON_ERROR
+ ) : [];
+ }
+
/**
* Sets target user object.
*
diff --git a/wcfsetup/install/files/lib/data/user/option/UserOptionEditor.class.php b/wcfsetup/install/files/lib/data/user/option/UserOptionEditor.class.php
index 857dfe5cf36..7fc4f7fa0bc 100644
--- a/wcfsetup/install/files/lib/data/user/option/UserOptionEditor.class.php
+++ b/wcfsetup/install/files/lib/data/user/option/UserOptionEditor.class.php
@@ -6,6 +6,7 @@
use wcf\data\IEditableCachedObject;
use wcf\system\cache\builder\UserOptionCacheBuilder;
use wcf\system\WCF;
+use wcf\util\StringUtil;
/**
* Provides functions to edit user options.
@@ -28,6 +29,13 @@ class UserOptionEditor extends DatabaseObjectEditor implements IEditableCachedOb
#[\Override]
public static function create(array $parameters = [])
{
+ if (!\array_key_exists('configuration', $parameters)) {
+ $parameters['configuration'] = self::buildConfiguration(
+ $parameters['selectOptions'] ?? '',
+ (bool)($parameters['required'] ?? false)
+ );
+ }
+
/** @var UserOption $userOption */
$userOption = parent::create($parameters);
@@ -52,6 +60,16 @@ public static function create(array $parameters = [])
#[\Override]
public function update(array $parameters = [])
{
+ if (
+ !\array_key_exists('configuration', $parameters)
+ && (\array_key_exists('selectOptions', $parameters) || \array_key_exists('required', $parameters))
+ ) {
+ $parameters['configuration'] = self::buildConfiguration(
+ $parameters['selectOptions'] ?? $this->selectOptions ?? '',
+ (bool)($parameters['required'] ?? $this->required)
+ );
+ }
+
parent::update($parameters);
// alter the table "wcf1_user_option_value" with this new option
@@ -170,6 +188,43 @@ public static function getColumnDefinition(string $optionType)
return $column;
}
+ /**
+ * Builds the JSON-encoded form option configuration from the legacy option data.
+ *
+ * @since 6.3
+ */
+ public static function buildConfiguration(string $selectOptions, bool $required): string
+ {
+ $configuration = [];
+ if ($required) {
+ $configuration['required'] = 1;
+ }
+
+ $selectOptions = StringUtil::trim(StringUtil::unifyNewlines($selectOptions));
+ if ($selectOptions !== '') {
+ $options = [];
+ foreach (\explode("\n", $selectOptions) as $selectOption) {
+ $key = $value = $selectOption;
+ if (\str_contains($selectOption, ':')) {
+ [$key, $value] = \explode(':', $selectOption, 2);
+ }
+
+ $options[] = [
+ 'key' => $key,
+ 'value' => [0 => $value],
+ ];
+ }
+
+ $configuration['selectOptions'] = \json_encode($options, \JSON_THROW_ON_ERROR);
+ }
+
+ if ($configuration === []) {
+ return '';
+ }
+
+ return \json_encode($configuration, \JSON_THROW_ON_ERROR);
+ }
+
#[\Override]
public static function resetCache()
{
diff --git a/wcfsetup/install/files/lib/form/SettingsForm.class.php b/wcfsetup/install/files/lib/form/SettingsForm.class.php
index 45b35d3ce6c..e2fa95c4982 100644
--- a/wcfsetup/install/files/lib/form/SettingsForm.class.php
+++ b/wcfsetup/install/files/lib/form/SettingsForm.class.php
@@ -2,110 +2,89 @@
namespace wcf\form;
+use wcf\command\user\SetColorScheme;
use wcf\data\language\Language;
+use wcf\data\option\category\OptionCategory;
use wcf\data\style\Style;
use wcf\data\trophy\Trophy;
use wcf\data\trophy\TrophyCache;
use wcf\data\user\option\category\UserOptionCategory;
+use wcf\data\user\option\UserOption;
use wcf\data\user\trophy\UserTrophyList;
use wcf\data\user\UserAction;
use wcf\data\user\UserProfile;
use wcf\data\user\UserProfileAction;
+use wcf\system\cache\builder\UserOptionCacheBuilder;
use wcf\system\exception\IllegalLinkException;
-use wcf\system\exception\UserInputException;
+use wcf\system\form\builder\container\FormContainer;
+use wcf\system\form\builder\field\AbstractFormField;
+use wcf\system\form\builder\field\ICensorshipFormField;
+use wcf\system\form\builder\field\IFormField;
+use wcf\system\form\builder\field\MultipleSelectionFormField;
+use wcf\system\form\builder\field\SelectFormField;
+use wcf\system\form\builder\field\validation\FormFieldValidationError;
+use wcf\system\form\builder\field\validation\FormFieldValidator;
use wcf\system\language\LanguageFactory;
use wcf\system\menu\user\UserMenu;
-use wcf\system\option\user\UserOptionHandler;
use wcf\system\request\LinkHandler;
use wcf\system\style\StyleHandler;
-use wcf\command\user\SetColorScheme;
use wcf\system\user\storage\UserStorageHandler;
use wcf\system\WCF;
-use wcf\util\ArrayUtil;
+use wcf\util\StringUtil;
/**
* Shows the dynamic options edit form.
*
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License
+ * @author Marcel Werk, Alexander Ebert
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ *
+ * @extends AbstractFormBuilderForm
*/
-class SettingsForm extends AbstractForm
+final class SettingsForm extends AbstractFormBuilderForm
{
/**
* @inheritDoc
*/
public $loginRequired = true;
- /**
- * user option handler
- * @var ?UserOptionHandler
- */
- public $optionHandler;
-
- /**
- * @inheritDoc
- */
- public $errorType = [];
-
/**
* option category
- * @var string
*/
- public $category = 'general';
+ public string $category = 'general';
/**
* list of available content languages
* @var Language[]
*/
- public $availableContentLanguages = [];
+ public array $availableContentLanguages = [];
/**
* list of available languages
* @var Language[]
*/
- public $availableLanguages = [];
+ public array $availableLanguages = [];
/**
* list of available styles
* @var Style[]
*/
- public $availableStyles = [];
+ public array $availableStyles = [];
/**
* list of available trophies
* @var Trophy[]
*/
- public $availableTrophies = [];
-
- /**
- * list of content language ids
- * @var int[]
- */
- public $contentLanguageIDs = [];
+ public array $availableTrophies = [];
/**
- * language id
- * @var int
+ * editable options grouped by the name of their category
+ * @var array
*/
- public $languageID = 0;
-
- /**
- * style id
- * @var int
- */
- public $styleID = 0;
-
- /**
- * special trophies
- * @var int[]
- */
- public $specialTrophies = [];
-
- public string $colorScheme = 'system';
+ protected array $options = [];
#[\Override]
- public function readParameters()
+ public function readParameters(): void
{
parent::readParameters();
@@ -118,8 +97,7 @@ public function readParameters()
}
}
- $this->optionHandler = new UserOptionHandler(false, '', 'settings.' . $this->category);
- $this->optionHandler->setUser(WCF::getUser());
+ $this->options = $this->getAvailableOptions('settings.' . $this->category);
if ($this->category === 'general') {
$this->availableContentLanguages = LanguageFactory::getInstance()->getContentLanguages();
@@ -134,193 +112,355 @@ public function readParameters()
$this->availableTrophies = TrophyCache::getInstance()->getTrophiesByID($trophyIDs);
Trophy::sort($this->availableTrophies, 'showOrder');
- } elseif ($this->optionHandler->countCategoryOptions('settings.' . $this->category) === 0) {
+ } elseif ($this->options === []) {
throw new IllegalLinkException();
}
}
#[\Override]
- public function readFormParameters()
+ protected function createForm(): void
{
- parent::readFormParameters();
-
- $this->optionHandler->readUserInput($_POST);
+ parent::createForm();
- // static options
if ($this->category === 'general') {
- if (isset($_POST['contentLanguageIDs']) && \is_array($_POST['contentLanguageIDs'])) {
- $this->contentLanguageIDs = ArrayUtil::toIntegerArray($_POST['contentLanguageIDs']);
- }
- if (isset($_POST['languageID'])) {
- $this->languageID = \intval($_POST['languageID']);
- }
- if (isset($_POST['styleID'])) {
- $this->styleID = \intval($_POST['styleID']);
+ $this->form->appendChildren($this->getGeneralFormContainers());
+ }
+
+ $categories = UserOptionCacheBuilder::getInstance()->getData([], 'categories');
+ foreach ($this->options as $categoryName => $options) {
+ $this->form->appendChild(
+ FormContainer::create('optionCategory' . $categories[$categoryName]->categoryID)
+ ->label('wcf.user.option.category.' . $categoryName)
+ ->appendChildren(\array_map($this->getOptionFormField(...), $options))
+ );
+ }
+ }
+
+ /**
+ * Returns the form containers for the static options of the general category.
+ *
+ * @return FormContainer[]
+ */
+ protected function getGeneralFormContainers(): array
+ {
+ $containers = [];
+
+ if (\count($this->availableLanguages) > 1) {
+ $languageContainer = FormContainer::create('languageSection')
+ ->label('wcf.user.language')
+ ->appendChild(
+ SelectFormField::create('languageID')
+ ->label('wcf.user.language.description')
+ ->options(\array_map(
+ static fn(Language $language) => StringUtil::encodeHTML($language->languageName),
+ $this->availableLanguages
+ ))
+ ->required()
+ ->ignoreInvalidValues()
+ ->value(WCF::getUser()->languageID)
+ );
+
+ if ($this->availableContentLanguages !== []) {
+ $languageContainer->appendChild(
+ MultipleSelectionFormField::create('contentLanguageIDs')
+ ->label('wcf.user.visibleLanguages')
+ ->description('wcf.user.visibleLanguages.description')
+ ->options(\array_map(
+ static fn(Language $language) => StringUtil::encodeHTML($language->languageName),
+ $this->availableContentLanguages
+ ))
+ ->ignoreInvalidValues()
+ ->value(WCF::getUser()->getLanguageIDs())
+ );
}
- if (isset($_POST['specialTrophies'])) {
- $this->specialTrophies = ArrayUtil::toIntegerArray($_POST['specialTrophies']);
+
+ $containers[] = $languageContainer;
+ }
+
+ $styleContainer = FormContainer::create('styleSection')
+ ->label('wcf.user.styles');
+ if (\count($this->availableStyles) > 1) {
+ $styles = [0 => 'wcf.global.defaultValue'];
+ foreach ($this->availableStyles as $style) {
+ $styles[$style->styleID] = StringUtil::encodeHTML($style->styleName);
}
- if (isset($_POST['colorScheme'])) {
- $this->colorScheme = $_POST['colorScheme'];
+
+ $styleContainer->appendChild(
+ SelectFormField::create('styleID')
+ ->label('wcf.user.style')
+ ->description('wcf.user.style.description')
+ ->options($styles)
+ ->required()
+ ->ignoreInvalidValues()
+ ->value(WCF::getUser()->styleID)
+ );
+ }
+ $styleContainer->appendChild(
+ SelectFormField::create('colorScheme')
+ ->label('wcf.user.style.colorScheme')
+ ->description('wcf.user.style.colorScheme.description')
+ ->options([
+ 'system' => 'wcf.style.setColorScheme.system',
+ 'light' => 'wcf.style.setColorScheme.light',
+ 'dark' => 'wcf.style.setColorScheme.dark',
+ ])
+ ->required()
+ ->ignoreInvalidValues()
+ ->value(WCF::getUser()->getUserOption('colorScheme') ?? 'system')
+ );
+ $containers[] = $styleContainer;
+
+ $maxSpecialTrophies = (int)WCF::getSession()->getPermission('user.profile.trophy.maxUserSpecialTrophies');
+ if (\MODULE_TROPHY !== 0 && $maxSpecialTrophies !== 0 && $this->availableTrophies !== []) {
+ $trophies = [];
+ foreach ($this->availableTrophies as $trophy) {
+ $trophies[$trophy->trophyID] = $trophy->renderTrophy(32) . ' ' . StringUtil::encodeHTML($trophy->getTitle());
}
+
+ $containers[] = FormContainer::create('trophySection')
+ ->label('wcf.user.trophy.trophies')
+ ->appendChild(
+ MultipleSelectionFormField::create('specialTrophies')
+ ->label('wcf.user.trophy.specialTrophies')
+ ->description('wcf.user.trophy.specialTrophies.description')
+ ->options($trophies)
+ ->addValidator(new FormFieldValidator(
+ 'maximumMultiples',
+ static function (MultipleSelectionFormField $formField) use ($maxSpecialTrophies) {
+ if (\count($formField->getValue() ?? []) > $maxSpecialTrophies) {
+ $formField->addValidationError(new FormFieldValidationError(
+ 'tooMany',
+ 'wcf.user.trophy.specialTrophies.error.tooMany'
+ ));
+ }
+ }
+ ))
+ ->value(\array_values(\array_unique(\array_map(
+ static fn(Trophy $trophy) => $trophy->trophyID,
+ (new UserProfile(WCF::getUser()))->getSpecialTrophies()
+ ))))
+ ->ignoreInvalidValues()
+ );
}
+
+ return $containers;
}
- #[\Override]
- public function validate()
+ /**
+ * Returns the form field for the given user option.
+ */
+ protected function getOptionFormField(UserOption $option): AbstractFormField
{
- parent::validate();
+ $formOption = $option->getFormOption();
+ $configuration = $option->getConfiguration();
- // dynamic options
- $optionErrors = $this->optionHandler->validate();
- if ($optionErrors !== []) {
- $this->errorType = $optionErrors;
- throw new UserInputException('options', $this->errorType);
+ $formField = $formOption->getFormField($option->getIdentifier(), $configuration)
+ ->label('wcf.user.option.' . $option->optionName);
+
+ $description = 'wcf.user.option.' . $option->optionName . '.description';
+ if (WCF::getLanguage()->get($description, true) !== '') {
+ $formField->description($description);
}
- // static options
- if ($this->category === 'general') {
- // validate language id
- if (!isset($this->availableLanguages[$this->languageID])) {
- $this->languageID = LanguageFactory::getInstance()->getDefaultLanguageID();
- }
+ if ((bool)($configuration['required'] ?? false)) {
+ $formField->required();
+ }
+
+ if ($formField instanceof ICensorshipFormField) {
+ $formField->censorship();
+ }
- // validate content language ids
- foreach ($this->contentLanguageIDs as $key => $languageID) {
- if (!isset($this->availableContentLanguages[$languageID])) {
- unset($this->contentLanguageIDs[$key]);
+ if ($formField instanceof SelectFormField && isset($formField->getOptions()[$option->defaultValue ?? ''])) {
+ $formField->defaultValue($option->defaultValue);
+ }
+
+ if (($option->validationPattern ?? '') !== '') {
+ $formField->addValidator(new FormFieldValidator(
+ 'validationPattern',
+ static function (IFormField $formField) use ($option) {
+ $value = (string)$formField->getValue();
+ if ($value !== '' && !\preg_match('~' . $option->validationPattern . '~', $value)) {
+ $formField->addValidationError(new FormFieldValidationError(
+ 'validationFailed',
+ 'wcf.user.option.error.validationFailed'
+ ));
+ }
}
- }
+ ));
+ }
- if ($this->contentLanguageIDs === [] && isset($this->availableContentLanguages[$this->languageID])) {
- $this->contentLanguageIDs[] = $this->languageID;
- }
+ $value = WCF::getUser()->{$option->getIdentifier()};
+ if ($value !== null) {
+ $formField->value($formOption->unserializeValue((string)$value));
+ }
- // validate style id
- if (!isset($this->availableStyles[$this->styleID])) {
- $this->styleID = 0;
+ return $formField;
+ }
+
+ /**
+ * Returns the options of the child categories of the given category that
+ * the active user can edit, grouped by the name of the child category.
+ *
+ * @return array
+ */
+ protected function getAvailableOptions(string $parentCategoryName): array
+ {
+ $categories = UserOptionCacheBuilder::getInstance()->getData([], 'categories');
+ $categoryStructure = UserOptionCacheBuilder::getInstance()->getData([], 'categoryStructure');
+
+ $options = [];
+ foreach ($categoryStructure[$parentCategoryName] ?? [] as $categoryName) {
+ if (!$this->isAccessibleCategory($categories[$categoryName])) {
+ continue;
}
- // validate special trophies
- if (\count($this->specialTrophies) > WCF::getSession()->getPermission('user.profile.trophy.maxUserSpecialTrophies')) {
- throw new UserInputException('specialTrophies', 'tooMany');
+ $categoryOptions = $this->getCategoryOptions($categoryName);
+ if ($categoryOptions !== []) {
+ $options[$categoryName] = $categoryOptions;
}
+ }
- foreach ($this->specialTrophies as $trophyID) {
- if (
- !\in_array($trophyID, \array_map(static function ($trophy) {
- return $trophy->trophyID;
- }, $this->availableTrophies), true)
- ) {
- throw new UserInputException('specialTrophies', 'invalid');
- }
+ return $options;
+ }
+
+ /**
+ * Returns the editable options of the given category and its child categories.
+ *
+ * @return UserOption[]
+ */
+ protected function getCategoryOptions(string $categoryName): array
+ {
+ $categories = UserOptionCacheBuilder::getInstance()->getData([], 'categories');
+ $categoryStructure = UserOptionCacheBuilder::getInstance()->getData([], 'categoryStructure');
+ $cachedOptions = UserOptionCacheBuilder::getInstance()->getData([], 'options');
+ $optionToCategories = UserOptionCacheBuilder::getInstance()->getData([], 'optionToCategories');
+
+ $options = [];
+ foreach ($optionToCategories[$categoryName] ?? [] as $optionName) {
+ $option = $cachedOptions[$optionName];
+ if ($option->validateOptions() && $option->validatePermissions() && $option->isEditable()) {
+ $options[] = $option;
}
+ }
- if ($this->colorScheme !== 'light' && $this->colorScheme !== 'dark') {
- $this->colorScheme = 'system';
+ foreach ($categoryStructure[$categoryName] ?? [] as $childCategoryName) {
+ if ($this->isAccessibleCategory($categories[$childCategoryName])) {
+ $options = \array_merge($options, $this->getCategoryOptions($childCategoryName));
}
}
+
+ return $options;
}
- #[\Override]
- public function readData()
+ private function isAccessibleCategory(OptionCategory $category): bool
{
- parent::readData();
-
- // default values
- if ($_POST === []) {
- // static options
- if ($this->category === 'general') {
- $this->contentLanguageIDs = WCF::getUser()->getLanguageIDs();
- if (isset($this->availableLanguages[WCF::getUser()->languageID])) {
- $this->languageID = WCF::getUser()->languageID;
- }
- $this->styleID = WCF::getUser()->styleID;
-
- $this->specialTrophies = \array_unique(\array_map(static function ($trophy) {
- return $trophy->trophyID;
- }, (new UserProfile(WCF::getUser()))->getSpecialTrophies()));
+ return $category->categoryName !== 'hidden'
+ && $category->validateOptions()
+ && $category->validatePermissions();
+ }
- $this->colorScheme = WCF::getUser()->getUserOption('colorScheme') ?? 'system';
- }
+ #[\Override]
+ protected function setFormAction()
+ {
+ $parameters = [];
+ if ($this->category !== 'general') {
+ $parameters['category'] = $this->category;
}
+
+ $this->form->action(LinkHandler::getInstance()->getControllerLink(static::class, $parameters));
}
#[\Override]
- public function save()
+ public function save(): void
{
- parent::save();
+ AbstractForm::save();
+
+ $formData = $this->form->getData();
+
+ $optionValues = [];
+ foreach ($this->options as $options) {
+ foreach ($options as $option) {
+ $key = $option->getIdentifier();
+
+ if (\array_key_exists($key, $formData['data'])) {
+ $value = $formData['data'][$key];
+ } elseif (\array_key_exists($key, $formData)) {
+ // multi-select fields place value at top-level via custom processor, see MultipleSelectionFormField::populate
+ $value = $formData[$key];
+ } else {
+ continue;
+ }
+
+ $optionValues[$option->optionID] = $value !== null
+ ? $option->getFormOption()->serializeValue($value)
+ : $option->defaultValue;
+ }
+ }
- $saveOptions = $this->optionHandler->save();
- $parameters = ['options' => $saveOptions];
- // static options
+ $parameters = ['options' => $optionValues];
if ($this->category === 'general') {
- $parameters['data'] = \array_merge($this->additionalFields, [
- 'languageID' => $this->languageID,
- 'styleID' => $this->styleID,
- ]);
- $parameters['languageIDs'] = $this->contentLanguageIDs;
+ $parameters['data'] = $this->additionalFields;
+
+ if (isset($formData['data']['languageID'])) {
+ $parameters['data']['languageID'] = (int)$formData['data']['languageID'];
+
+ $contentLanguageIDs = \array_map(\intval(...), $formData['contentLanguageIDs'] ?? []);
+ if ($contentLanguageIDs === [] && isset($this->availableContentLanguages[$parameters['data']['languageID']])) {
+ $contentLanguageIDs[] = $parameters['data']['languageID'];
+ }
+ $parameters['languageIDs'] = $contentLanguageIDs;
+ }
+
+ if (isset($formData['data']['styleID'])) {
+ $parameters['data']['styleID'] = (int)$formData['data']['styleID'];
+ }
}
$this->objectAction = new UserAction([WCF::getUser()], 'update', $parameters);
$this->objectAction->executeAction();
- // static options
if ($this->category === 'general') {
// reset user language ids cache
UserStorageHandler::getInstance()->reset([WCF::getUser()->userID], 'languageIDs');
- $userProfileAction = new UserProfileAction([WCF::getUser()->userID], 'updateSpecialTrophies', [
- 'trophyIDs' => $this->specialTrophies,
- ]);
- $userProfileAction->executeAction();
+ if ($this->form->getNodeById('specialTrophies') !== null) {
+ $userProfileAction = new UserProfileAction([WCF::getUser()->userID], 'updateSpecialTrophies', [
+ 'trophyIDs' => \array_map(\intval(...), $formData['specialTrophies'] ?? []),
+ ]);
+ $userProfileAction->executeAction();
+ }
- if (WCF::getUser()->getUserOption('colorScheme') !== $this->colorScheme) {
- new SetColorScheme(WCF::getUser(), $this->colorScheme)();
+ $colorScheme = $formData['data']['colorScheme'] ?? 'system';
+ if (WCF::getUser()->getUserOption('colorScheme') !== $colorScheme) {
+ new SetColorScheme(WCF::getUser(), $colorScheme)();
}
}
+
$this->saved();
+ }
+
+ #[\Override]
+ public function saved(): void
+ {
+ AbstractForm::saved();
- WCF::getTPL()->assign('success', true);
+ // The form is intentionally not rebuilt, because `WCF::getUser()` still
+ // holds the values from before saving.
+ $this->form->showSuccessMessage(true);
}
#[\Override]
- public function assignVariables()
+ public function assignVariables(): void
{
parent::assignVariables();
- $formActionParameters = [];
- if ($this->category !== 'general') {
- $formActionParameters['category'] = $this->category;
- }
-
WCF::getTPL()->assign([
- 'optionTree' => $this->optionHandler->getOptionTree(),
'category' => $this->category,
- 'formAction' => LinkHandler::getInstance()->getControllerLink(
- static::class,
- $formActionParameters
- ),
]);
-
- // static options
- if ($this->category === 'general') {
- WCF::getTPL()->assign([
- 'availableContentLanguages' => $this->availableContentLanguages,
- 'availableLanguages' => $this->availableLanguages,
- 'availableStyles' => $this->availableStyles,
- 'availableTrophies' => $this->availableTrophies,
- 'contentLanguageIDs' => $this->contentLanguageIDs,
- 'languageID' => $this->languageID,
- 'styleID' => $this->styleID,
- 'specialTrophies' => $this->specialTrophies,
- 'colorScheme' => $this->colorScheme,
- ]);
- }
}
#[\Override]
- public function show()
+ public function show(): void
{
// set active tab
UserMenu::getInstance()->setActiveMenuItem('wcf.user.option.category.settings.' . $this->category);
diff --git a/wcfsetup/install/files/lib/system/form/option/FormOptionHandler.class.php b/wcfsetup/install/files/lib/system/form/option/FormOptionHandler.class.php
index 0f13266d8e4..2d2271efe1b 100644
--- a/wcfsetup/install/files/lib/system/form/option/FormOptionHandler.class.php
+++ b/wcfsetup/install/files/lib/system/form/option/FormOptionHandler.class.php
@@ -51,6 +51,7 @@ private function getDefaultFormOptions(): array
new SourceCodeFormOption(),
new TextFormOption(),
new TextareaFormOption(),
+ new TimezoneFormOption(),
new UrlFormOption(),
new WysiwygFormOption(),
] as $defaultOption
diff --git a/wcfsetup/install/files/lib/system/form/option/TimezoneFormOption.class.php b/wcfsetup/install/files/lib/system/form/option/TimezoneFormOption.class.php
new file mode 100644
index 00000000000..36b6d1744f3
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/form/option/TimezoneFormOption.class.php
@@ -0,0 +1,52 @@
+
+ * @since 6.3
+ */
+class TimezoneFormOption extends AbstractFormOption
+{
+ #[\Override]
+ public function getId(): string
+ {
+ return 'timezone';
+ }
+
+ #[\Override]
+ public function getFormField(string $id, array $configuration = []): AbstractFormField
+ {
+ $timezones = [];
+ foreach (DateUtil::getAvailableTimezones() as $timezone) {
+ $timezones[$timezone] = 'wcf.date.timezone.' . \str_replace('/', '.', \strtolower($timezone));
+ }
+
+ $formField = SelectFormField::create($id)
+ ->options($timezones)
+ ->ignoreInvalidValues();
+
+ if (isset($timezones[\TIMEZONE])) {
+ $formField->defaultValue(\TIMEZONE);
+ }
+
+ return $formField;
+ }
+
+ #[\Override]
+ public function getDatabaseTableColumn(string $name): AbstractDatabaseTableColumn
+ {
+ return VarcharDatabaseTableColumn::create($name)
+ ->length(255);
+ }
+}
diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml
index 76561c2604a..85a2197c493 100644
--- a/wcfsetup/install/lang/de.xml
+++ b/wcfsetup/install/lang/de.xml
@@ -4099,6 +4099,7 @@ Dateianhänge:
+
diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml
index 35f06cddc81..587c1bf92d2 100644
--- a/wcfsetup/install/lang/en.xml
+++ b/wcfsetup/install/lang/en.xml
@@ -4045,6 +4045,7 @@ Attachments:
+
diff --git a/wcfsetup/setup/db/install_com.woltlab.wcf.php b/wcfsetup/setup/db/install_com.woltlab.wcf.php
index 03b8be44f41..58b73ba8f86 100644
--- a/wcfsetup/setup/db/install_com.woltlab.wcf.php
+++ b/wcfsetup/setup/db/install_com.woltlab.wcf.php
@@ -4823,6 +4823,7 @@
DefaultFalseBooleanDatabaseTableColumn::create('showOnUserCard'),
VarcharDatabaseTableColumn::create('l10nIdentifier')
->length(255),
+ MediumtextDatabaseTableColumn::create('configuration'),
])
->indices([
DatabaseTablePrimaryIndex::create()
From cc587eed3371b7989dbb963614649ae362c3f2de Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Thu, 1 Oct 2026 21:07:10 +0200
Subject: [PATCH 002/115] Add `UserOptionBuilder` with create/update/delete
commands
---
...oltlab.wcf_6.3_userOptionConfiguration.php | 6 +-
.../user/option/CreateOption.class.php | 34 +++++++
.../user/option/DeleteOption.class.php | 5 +-
.../user/option/UpdateOption.class.php | 34 +++++++
.../user/option/UserOptionBuilder.class.php | 88 +++++++++++++++++++
.../user/option/UserOptionEditor.class.php | 42 +--------
6 files changed, 163 insertions(+), 46 deletions(-)
create mode 100644 wcfsetup/install/files/lib/command/user/option/CreateOption.class.php
create mode 100644 wcfsetup/install/files/lib/command/user/option/UpdateOption.class.php
diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userOptionConfiguration.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userOptionConfiguration.php
index 528839c9283..70778b01e24 100644
--- a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userOptionConfiguration.php
+++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userOptionConfiguration.php
@@ -8,7 +8,7 @@
* @license GNU Lesser General Public License
*/
-use wcf\data\user\option\UserOptionEditor;
+use wcf\data\user\option\UserOptionBuilder;
use wcf\system\WCF;
$sql = "SELECT optionID, selectOptions, required
@@ -17,7 +17,7 @@
$statement->execute();
$configurations = [];
while ($row = $statement->fetchArray()) {
- $configurations[$row['optionID']] = UserOptionEditor::buildConfiguration(
+ $configurations[$row['optionID']] = UserOptionBuilder::buildConfiguration(
$row['selectOptions'] ?? '',
(bool)$row['required']
);
@@ -37,4 +37,4 @@
}
WCF::getDB()->commitTransaction();
-UserOptionEditor::resetCache();
+UserOptionBuilder::resetCache();
diff --git a/wcfsetup/install/files/lib/command/user/option/CreateOption.class.php b/wcfsetup/install/files/lib/command/user/option/CreateOption.class.php
new file mode 100644
index 00000000000..6daf1b7c206
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/user/option/CreateOption.class.php
@@ -0,0 +1,34 @@
+
+ * @since 6.3
+ */
+final class CreateOption
+{
+ public function __construct(
+ private readonly UserOptionBuilder $builder,
+ ) {}
+
+ public function __invoke(): UserOption
+ {
+ $option = $this->builder->create();
+
+ UserOptionBuilder::resetCache();
+
+ EventHandler::getInstance()->fire(new UserOptionCreated($option, $this->builder));
+
+ return $option;
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/user/option/DeleteOption.class.php b/wcfsetup/install/files/lib/command/user/option/DeleteOption.class.php
index 96fbfcc5204..da3e3f75a51 100644
--- a/wcfsetup/install/files/lib/command/user/option/DeleteOption.class.php
+++ b/wcfsetup/install/files/lib/command/user/option/DeleteOption.class.php
@@ -5,14 +5,13 @@
use wcf\data\user\option\UserOption;
use wcf\data\user\option\UserOptionBuilder;
use wcf\event\user\option\UserOptionDeleted;
-use wcf\system\cache\builder\UserOptionCacheBuilder;
use wcf\system\event\EventHandler;
/**
* Deletes a user option.
*
* @author Marcel Werk
- * @copyright 2001-2024 WoltLab GmbH
+ * @copyright 2001-2026 WoltLab GmbH
* @license GNU Lesser General Public License
* @since 6.2
*/
@@ -26,7 +25,7 @@ public function __invoke(): void
{
UserOptionBuilder::delete($this->option);
- UserOptionCacheBuilder::getInstance()->reset();
+ UserOptionBuilder::resetCache();
EventHandler::getInstance()->fire(new UserOptionDeleted($this->option));
}
diff --git a/wcfsetup/install/files/lib/command/user/option/UpdateOption.class.php b/wcfsetup/install/files/lib/command/user/option/UpdateOption.class.php
new file mode 100644
index 00000000000..5eeadff82e6
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/user/option/UpdateOption.class.php
@@ -0,0 +1,34 @@
+
+ * @since 6.3
+ */
+final class UpdateOption
+{
+ public function __construct(
+ private readonly UserOptionBuilder $builder,
+ ) {}
+
+ public function __invoke(): UserOption
+ {
+ $option = $this->builder->update();
+
+ UserOptionBuilder::resetCache();
+
+ EventHandler::getInstance()->fire(new UserOptionUpdated($option, $this->builder));
+
+ return $option;
+ }
+}
diff --git a/wcfsetup/install/files/lib/data/user/option/UserOptionBuilder.class.php b/wcfsetup/install/files/lib/data/user/option/UserOptionBuilder.class.php
index 4baf1a987fa..c9415154a60 100644
--- a/wcfsetup/install/files/lib/data/user/option/UserOptionBuilder.class.php
+++ b/wcfsetup/install/files/lib/data/user/option/UserOptionBuilder.class.php
@@ -4,6 +4,7 @@
use wcf\data\DatabaseObject;
use wcf\data\DatabaseObjectBuilder;
+use wcf\system\cache\builder\UserOptionCacheBuilder;
use wcf\system\l10n\L10nStorage;
use wcf\system\WCF;
use wcf\util\StringUtil;
@@ -29,6 +30,12 @@ final class UserOptionBuilder extends DatabaseObjectBuilder
{
private bool $isGenericOptionName = false;
+ /**
+ * Is `true` if the configuration has been set explicitly and must not be
+ * derived from the select options and the required flag.
+ */
+ private bool $hasExplicitConfiguration = false;
+
/**
* @var L10nValue
*/
@@ -107,6 +114,10 @@ public function setCategoryName(string $categoryName): static
return $this;
}
+ /**
+ * Sets the legacy option type, see `UserOption::getFormOption()` for the
+ * mapping to form options.
+ */
public function setOptionType(string $optionType): static
{
$this->properties['optionType'] = $optionType;
@@ -128,9 +139,13 @@ public function setValidationPattern(string $validationPattern): static
return $this;
}
+ /**
+ * Sets the select options in the legacy format, one `value:label` pair per line.
+ */
public function setSelectOptions(string $selectOptions): static
{
$this->properties['selectOptions'] = $selectOptions;
+ $this->syncConfiguration();
return $this;
}
@@ -180,6 +195,7 @@ public function setVisible(int $visible): static
public function setRequired(bool $required): static
{
$this->properties['required'] = $required ? 1 : 0;
+ $this->syncConfiguration();
return $this;
}
@@ -243,6 +259,22 @@ public function setAdditionalData(array $additionalData): static
return $this;
}
+ /**
+ * Sets the form option configuration explicitly instead of deriving it
+ * from the select options and the required flag.
+ *
+ * @param array $configuration
+ */
+ public function setConfiguration(array $configuration): static
+ {
+ $this->hasExplicitConfiguration = true;
+ $this->properties['configuration'] = $configuration !== []
+ ? \json_encode($configuration, \JSON_THROW_ON_ERROR)
+ : '';
+
+ return $this;
+ }
+
#[\Override]
protected function getRequiredProperties(): array
{
@@ -325,4 +357,60 @@ protected static function beforeDeleteAll(array $objectIDs): void
WCF::getDB()->getEditor()->dropColumn('wcf1_user_option_value', 'userOption' . $objectID);
}
}
+
+ /**
+ * Derives the configuration from the select options and the required flag,
+ * falling back to the values of the updated option for the missing one.
+ */
+ private function syncConfiguration(): void
+ {
+ if ($this->hasExplicitConfiguration) {
+ return;
+ }
+
+ $this->properties['configuration'] = self::buildConfiguration(
+ (string)($this->properties['selectOptions'] ?? ($this->isUpdate() ? $this->getObject()->selectOptions : '')),
+ (bool)($this->properties['required'] ?? ($this->isUpdate() ? $this->getObject()->required : false))
+ );
+ }
+
+ /**
+ * Builds the JSON-encoded form option configuration from the legacy option data.
+ */
+ public static function buildConfiguration(string $selectOptions, bool $required): string
+ {
+ $configuration = [];
+ if ($required) {
+ $configuration['required'] = 1;
+ }
+
+ $selectOptions = StringUtil::trim(StringUtil::unifyNewlines($selectOptions));
+ if ($selectOptions !== '') {
+ $options = [];
+ foreach (\explode("\n", $selectOptions) as $selectOption) {
+ $key = $value = $selectOption;
+ if (\str_contains($selectOption, ':')) {
+ [$key, $value] = \explode(':', $selectOption, 2);
+ }
+
+ $options[] = [
+ 'key' => $key,
+ 'value' => [0 => $value],
+ ];
+ }
+
+ $configuration['selectOptions'] = \json_encode($options, \JSON_THROW_ON_ERROR);
+ }
+
+ if ($configuration === []) {
+ return '';
+ }
+
+ return \json_encode($configuration, \JSON_THROW_ON_ERROR);
+ }
+
+ public static function resetCache(): void
+ {
+ UserOptionCacheBuilder::getInstance()->reset();
+ }
}
diff --git a/wcfsetup/install/files/lib/data/user/option/UserOptionEditor.class.php b/wcfsetup/install/files/lib/data/user/option/UserOptionEditor.class.php
index 7fc4f7fa0bc..395f2ced131 100644
--- a/wcfsetup/install/files/lib/data/user/option/UserOptionEditor.class.php
+++ b/wcfsetup/install/files/lib/data/user/option/UserOptionEditor.class.php
@@ -6,7 +6,6 @@
use wcf\data\IEditableCachedObject;
use wcf\system\cache\builder\UserOptionCacheBuilder;
use wcf\system\WCF;
-use wcf\util\StringUtil;
/**
* Provides functions to edit user options.
@@ -30,7 +29,7 @@ class UserOptionEditor extends DatabaseObjectEditor implements IEditableCachedOb
public static function create(array $parameters = [])
{
if (!\array_key_exists('configuration', $parameters)) {
- $parameters['configuration'] = self::buildConfiguration(
+ $parameters['configuration'] = UserOptionBuilder::buildConfiguration(
$parameters['selectOptions'] ?? '',
(bool)($parameters['required'] ?? false)
);
@@ -64,7 +63,7 @@ public function update(array $parameters = [])
!\array_key_exists('configuration', $parameters)
&& (\array_key_exists('selectOptions', $parameters) || \array_key_exists('required', $parameters))
) {
- $parameters['configuration'] = self::buildConfiguration(
+ $parameters['configuration'] = UserOptionBuilder::buildConfiguration(
$parameters['selectOptions'] ?? $this->selectOptions ?? '',
(bool)($parameters['required'] ?? $this->required)
);
@@ -188,43 +187,6 @@ public static function getColumnDefinition(string $optionType)
return $column;
}
- /**
- * Builds the JSON-encoded form option configuration from the legacy option data.
- *
- * @since 6.3
- */
- public static function buildConfiguration(string $selectOptions, bool $required): string
- {
- $configuration = [];
- if ($required) {
- $configuration['required'] = 1;
- }
-
- $selectOptions = StringUtil::trim(StringUtil::unifyNewlines($selectOptions));
- if ($selectOptions !== '') {
- $options = [];
- foreach (\explode("\n", $selectOptions) as $selectOption) {
- $key = $value = $selectOption;
- if (\str_contains($selectOption, ':')) {
- [$key, $value] = \explode(':', $selectOption, 2);
- }
-
- $options[] = [
- 'key' => $key,
- 'value' => [0 => $value],
- ];
- }
-
- $configuration['selectOptions'] = \json_encode($options, \JSON_THROW_ON_ERROR);
- }
-
- if ($configuration === []) {
- return '';
- }
-
- return \json_encode($configuration, \JSON_THROW_ON_ERROR);
- }
-
#[\Override]
public static function resetCache()
{
From 03cf16bf6ef54dc8c73c0b1748750359637a42f2 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Thu, 1 Oct 2026 21:45:59 +0200
Subject: [PATCH 003/115] Set default values for required language, style and
color scheme selects in SettingsForm
---
wcfsetup/install/files/lib/form/SettingsForm.class.php | 3 +++
1 file changed, 3 insertions(+)
diff --git a/wcfsetup/install/files/lib/form/SettingsForm.class.php b/wcfsetup/install/files/lib/form/SettingsForm.class.php
index e2fa95c4982..848ea081642 100644
--- a/wcfsetup/install/files/lib/form/SettingsForm.class.php
+++ b/wcfsetup/install/files/lib/form/SettingsForm.class.php
@@ -158,6 +158,7 @@ protected function getGeneralFormContainers(): array
->required()
->ignoreInvalidValues()
->value(WCF::getUser()->languageID)
+ ->defaultValue(LanguageFactory::getInstance()->getDefaultLanguageID())
);
if ($this->availableContentLanguages !== []) {
@@ -193,6 +194,7 @@ protected function getGeneralFormContainers(): array
->required()
->ignoreInvalidValues()
->value(WCF::getUser()->styleID)
+ ->defaultValue(0)
);
}
$styleContainer->appendChild(
@@ -207,6 +209,7 @@ protected function getGeneralFormContainers(): array
->required()
->ignoreInvalidValues()
->value(WCF::getUser()->getUserOption('colorScheme') ?? 'system')
+ ->defaultValue('system')
);
$containers[] = $styleContainer;
From 3918c7625193870b39241607586611565bebfa88 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Fri, 2 Oct 2026 13:37:10 +0200
Subject: [PATCH 004/115] Add `UserBuilder` with create/update/delete commands
and migrate `SettingsForm` to it
---
.../lib/command/user/CreateUser.class.php | 41 ++
.../lib/command/user/DeleteUser.class.php | 30 ++
.../lib/command/user/UpdateUser.class.php | 97 +++++
.../files/lib/data/user/UserBuilder.class.php | 409 ++++++++++++++++++
.../lib/event/user/UserCreated.class.php | 23 +
.../lib/event/user/UserDeleted.class.php | 21 +
.../lib/event/user/UserRenamed.class.php | 23 +
.../lib/event/user/UserUpdated.class.php | 23 +
.../files/lib/form/SettingsForm.class.php | 175 ++++----
9 files changed, 750 insertions(+), 92 deletions(-)
create mode 100644 wcfsetup/install/files/lib/command/user/CreateUser.class.php
create mode 100644 wcfsetup/install/files/lib/command/user/DeleteUser.class.php
create mode 100644 wcfsetup/install/files/lib/command/user/UpdateUser.class.php
create mode 100644 wcfsetup/install/files/lib/data/user/UserBuilder.class.php
create mode 100644 wcfsetup/install/files/lib/event/user/UserCreated.class.php
create mode 100644 wcfsetup/install/files/lib/event/user/UserDeleted.class.php
create mode 100644 wcfsetup/install/files/lib/event/user/UserRenamed.class.php
create mode 100644 wcfsetup/install/files/lib/event/user/UserUpdated.class.php
diff --git a/wcfsetup/install/files/lib/command/user/CreateUser.class.php b/wcfsetup/install/files/lib/command/user/CreateUser.class.php
new file mode 100644
index 00000000000..986166fbff6
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/user/CreateUser.class.php
@@ -0,0 +1,41 @@
+
+ * @since 6.3
+ */
+final class CreateUser
+{
+ public function __construct(
+ private readonly UserBuilder $builder,
+ ) {}
+
+ public function __invoke(): User
+ {
+ // The object returned by the builder lacks the user options.
+ $user = new User($this->builder->create()->userID);
+
+ if (\MODULE_USER_RANK !== 0) {
+ $action = new UserProfileAction([$user], 'updateUserRank');
+ $action->executeAction();
+ }
+ $action = new UserProfileAction([$user], 'updateUserOnlineMarking');
+ $action->executeAction();
+
+ EventHandler::getInstance()->fire(new UserCreated($user, $this->builder));
+
+ return $user;
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/user/DeleteUser.class.php b/wcfsetup/install/files/lib/command/user/DeleteUser.class.php
new file mode 100644
index 00000000000..de937fc33ec
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/user/DeleteUser.class.php
@@ -0,0 +1,30 @@
+
+ * @since 6.3
+ */
+final class DeleteUser
+{
+ public function __construct(
+ private readonly User $user,
+ ) {}
+
+ public function __invoke(): void
+ {
+ UserBuilder::delete($this->user);
+
+ EventHandler::getInstance()->fire(new UserDeleted($this->user));
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/user/UpdateUser.class.php b/wcfsetup/install/files/lib/command/user/UpdateUser.class.php
new file mode 100644
index 00000000000..bd158067ad0
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/user/UpdateUser.class.php
@@ -0,0 +1,97 @@
+
+ * @since 6.3
+ */
+final class UpdateUser
+{
+ public function __construct(
+ private readonly UserBuilder $builder,
+ ) {}
+
+ public function __invoke(): User
+ {
+ $oldUser = $this->builder->getObject();
+ $this->builder->update();
+
+ // The object returned by the builder lacks the changes to the user options.
+ $user = new User($oldUser->userID);
+
+ if ($user->username !== $oldUser->username) {
+ $this->rename($user, $oldUser->username);
+ }
+
+ if ($this->builder->isPasswordChanged()) {
+ SessionHandler::getInstance()->deleteUserSessionsExcept(
+ $user,
+ SessionHandler::getInstance()->sessionID
+ );
+ }
+
+ if ($user->userID === WCF::getUser()->userID && $user->languageID !== $oldUser->languageID) {
+ WCF::setLanguage($user->languageID);
+ }
+
+ if ($this->builder->getLanguageIDs() !== null) {
+ UserStorageHandler::getInstance()->reset([$user->userID], 'languageIDs');
+ }
+
+ if ($this->builder->getGroupIDs() !== null) {
+ UserStorageHandler::getInstance()->reset([$user->userID], 'groupIDs');
+
+ UserGroupAssignmentHandler::getInstance()->checkUsers([$user->userID]);
+
+ if (\MODULE_USER_RANK !== 0) {
+ $action = new UserProfileAction([$user], 'updateUserRank');
+ $action->executeAction();
+ }
+ if (\MODULE_USERS_ONLINE !== 0) {
+ $action = new UserProfileAction([$user], 'updateUserOnlineMarking');
+ $action->executeAction();
+ }
+ }
+
+ EventHandler::getInstance()->fire(new UserUpdated($user, $this->builder));
+
+ return $user;
+ }
+
+ /**
+ * Updates the username that is stored alongside the user id in other tables.
+ */
+ private function rename(User $user, string $oldUsername): void
+ {
+ WCF::getDB()->beginTransaction();
+ foreach (['wcf1_article', 'wcf1_comment', 'wcf1_comment_response', 'wcf1_media', 'wcf1_modification_log'] as $tableName) {
+ $sql = "UPDATE {$tableName}
+ SET username = ?
+ WHERE userID = ?";
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute([
+ $user->username,
+ $user->userID,
+ ]);
+ }
+ WCF::getDB()->commitTransaction();
+
+ EventHandler::getInstance()->fire(new UserRenamed($user, $oldUsername));
+ }
+}
diff --git a/wcfsetup/install/files/lib/data/user/UserBuilder.class.php b/wcfsetup/install/files/lib/data/user/UserBuilder.class.php
new file mode 100644
index 00000000000..ce23a0bbdbe
--- /dev/null
+++ b/wcfsetup/install/files/lib/data/user/UserBuilder.class.php
@@ -0,0 +1,409 @@
+
+ * @since 6.3
+ *
+ * @extends DatabaseObjectBuilder
+ */
+final class UserBuilder extends DatabaseObjectBuilder
+{
+ /**
+ * values of the user options by option id, `null` if the user options are not changed
+ * @var ?array
+ */
+ private ?array $userOptions = null;
+
+ /**
+ * ids of the user groups, `null` if the user groups are not changed
+ * @var ?list
+ */
+ private ?array $groupIDs = null;
+
+ /**
+ * ids of the content languages, `null` if the content languages are not changed
+ * @var ?list
+ */
+ private ?array $languageIDs = null;
+
+ private bool $passwordChanged = false;
+
+ public function setUsername(string $username): static
+ {
+ $this->properties['username'] = $username;
+
+ return $this;
+ }
+
+ public function setEmail(string $email): static
+ {
+ $this->properties['email'] = $email;
+
+ return $this;
+ }
+
+ /**
+ * Sets the password of the user, which is stored as a hash. Passing `null`
+ * sets an invalid password that prevents logging in with a password.
+ *
+ * Changing the password also changes the access token of the user.
+ */
+ public function setPassword(
+ #[\SensitiveParameter]
+ ?string $password
+ ): static {
+ $this->properties['password'] = self::getPasswordHash($password);
+ $this->properties['accessToken'] = Hex::encode(\random_bytes(20));
+ $this->passwordChanged = true;
+
+ return $this;
+ }
+
+ public function setLanguageID(int $languageID): static
+ {
+ $this->properties['languageID'] = $languageID;
+
+ return $this;
+ }
+
+ /**
+ * Sets the id of the style chosen by the user, `0` uses the default style.
+ */
+ public function setStyleID(int $styleID): static
+ {
+ $this->properties['styleID'] = $styleID;
+
+ return $this;
+ }
+
+ public function setRegistrationDate(int $registrationDate): static
+ {
+ $this->properties['registrationDate'] = $registrationDate;
+
+ return $this;
+ }
+
+ public function setRegistrationIpAddress(string $registrationIpAddress): static
+ {
+ $this->properties['registrationIpAddress'] = $registrationIpAddress;
+
+ return $this;
+ }
+
+ /**
+ * Sets the activation code, `0` marks the user as activated.
+ */
+ public function setActivationCode(int $activationCode): static
+ {
+ $this->properties['activationCode'] = $activationCode;
+
+ return $this;
+ }
+
+ /**
+ * Sets the code to confirm the email address, `null` marks the email
+ * address as confirmed.
+ */
+ public function setEmailConfirmed(?string $emailConfirmed): static
+ {
+ $this->properties['emailConfirmed'] = $emailConfirmed;
+
+ return $this;
+ }
+
+ public function setBanned(bool $banned): static
+ {
+ $this->properties['banned'] = (int)$banned;
+
+ return $this;
+ }
+
+ public function setBanReason(?string $banReason): static
+ {
+ $this->properties['banReason'] = $banReason;
+
+ return $this;
+ }
+
+ /**
+ * Sets the timestamp at which the ban expires, `0` bans the user permanently.
+ */
+ public function setBanExpires(int $banExpires): static
+ {
+ $this->properties['banExpires'] = $banExpires;
+
+ return $this;
+ }
+
+ public function setSignature(?string $signature): static
+ {
+ $this->properties['signature'] = $signature;
+
+ return $this;
+ }
+
+ public function setSignatureEnableHtml(bool $signatureEnableHtml): static
+ {
+ $this->properties['signatureEnableHtml'] = (int)$signatureEnableHtml;
+
+ return $this;
+ }
+
+ public function setDisableSignature(bool $disableSignature): static
+ {
+ $this->properties['disableSignature'] = (int)$disableSignature;
+
+ return $this;
+ }
+
+ public function setDisableSignatureReason(?string $disableSignatureReason): static
+ {
+ $this->properties['disableSignatureReason'] = $disableSignatureReason;
+
+ return $this;
+ }
+
+ public function setDisableSignatureExpires(int $disableSignatureExpires): static
+ {
+ $this->properties['disableSignatureExpires'] = $disableSignatureExpires;
+
+ return $this;
+ }
+
+ public function setUserTitle(string $userTitle): static
+ {
+ $this->properties['userTitle'] = $userTitle;
+
+ return $this;
+ }
+
+ public function setLastUsernameChange(int $lastUsernameChange): static
+ {
+ $this->properties['lastUsernameChange'] = $lastUsernameChange;
+
+ return $this;
+ }
+
+ public function setOldUsername(string $oldUsername): static
+ {
+ $this->properties['oldUsername'] = $oldUsername;
+
+ return $this;
+ }
+
+ public function setAuthData(string $authData): static
+ {
+ $this->properties['authData'] = $authData;
+
+ return $this;
+ }
+
+ /**
+ * Sets the value of a user option, see `UserOption::getFormOption()` for
+ * serializing form values.
+ */
+ public function setUserOption(int $optionID, ?string $value): static
+ {
+ $this->userOptions ??= [];
+ $this->userOptions[$optionID] = $value;
+
+ return $this;
+ }
+
+ /**
+ * Sets the user groups of the user, replacing the existing ones. New users
+ * are always added to the default groups `EVERYONE` and `USERS`.
+ *
+ * @param list $groupIDs
+ */
+ public function setGroupIDs(array $groupIDs): static
+ {
+ $this->groupIDs = $groupIDs;
+
+ return $this;
+ }
+
+ /**
+ * Sets the content languages of the user, replacing the existing ones. An
+ * empty list falls back to the default language.
+ *
+ * @param list $languageIDs
+ */
+ public function setLanguageIDs(array $languageIDs): static
+ {
+ $this->languageIDs = $languageIDs;
+
+ return $this;
+ }
+
+ /**
+ * @return ?array
+ */
+ public function getUserOptions(): ?array
+ {
+ return $this->userOptions;
+ }
+
+ /**
+ * @return ?list
+ */
+ public function getGroupIDs(): ?array
+ {
+ return $this->groupIDs;
+ }
+
+ /**
+ * @return ?list
+ */
+ public function getLanguageIDs(): ?array
+ {
+ return $this->languageIDs;
+ }
+
+ public function isPasswordChanged(): bool
+ {
+ return $this->passwordChanged;
+ }
+
+ #[\Override]
+ protected function getRequiredProperties(): array
+ {
+ return ['username', 'email', 'password', 'registrationDate'];
+ }
+
+ #[\Override]
+ protected function afterCreate(DatabaseObject $object): void
+ {
+ $this->createUserOptions($object);
+
+ $editor = new UserEditor($object);
+ $editor->addToGroups($this->groupIDs ?? [], false, true);
+ $editor->addToLanguages($this->languageIDs ?? [], false);
+
+ if (\PACKAGE_ID !== 0) {
+ // set default notifications
+ $sql = "INSERT INTO wcf1_user_notification_event_to_user
+ (userID, eventID, mailNotificationType)
+ SELECT ?, eventID, presetMailNotificationType
+ FROM wcf1_user_notification_event
+ WHERE preset = ?";
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute([$object->userID, 1]);
+ }
+ }
+
+ #[\Override]
+ protected function afterUpdate(DatabaseObject $object): void
+ {
+ $editor = new UserEditor($object);
+
+ if ($this->userOptions !== null) {
+ $editor->updateUserOptions($this->userOptions);
+ }
+
+ if ($this->groupIDs !== null) {
+ $editor->addToGroups($this->groupIDs, true, false);
+ }
+
+ if ($this->languageIDs !== null) {
+ $editor->addToLanguages($this->languageIDs, true);
+ }
+ }
+
+ #[\Override]
+ protected static function beforeDeleteAll(array $objectIDs): void
+ {
+ ClipboardHandler::getInstance()->unmark(
+ $objectIDs,
+ ClipboardHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.user')
+ );
+
+ $conditionBuilder = new PreparedStatementConditionBuilder();
+ $conditionBuilder->add('userID IN (?)', [$objectIDs]);
+ $conditionBuilder->add('avatarFileID IS NOT NULL');
+
+ $sql = "SELECT avatarFileID
+ FROM wcf1_user
+ {$conditionBuilder}";
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute($conditionBuilder->getParameters());
+ $avatarFiles = File::findByIDs($statement->fetchAll(\PDO::FETCH_COLUMN));
+ if ($avatarFiles !== []) {
+ new DeleteFiles($avatarFiles)();
+ }
+
+ CommentHandler::getInstance()->deleteObjects('com.woltlab.wcf.user.profileComment', $objectIDs);
+ AttachmentHandler::removeAttachments('com.woltlab.wcf.user.signature', $objectIDs);
+ }
+
+ /**
+ * Inserts the row with the user option values, using the default value of
+ * the options that have not been set explicitly.
+ */
+ private function createUserOptions(User $user): void
+ {
+ $sql = "SELECT optionID, defaultValue
+ FROM wcf1_user_option
+ WHERE defaultValue IS NOT NULL";
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute();
+ $values = $statement->fetchMap('optionID', 'defaultValue');
+
+ foreach ($this->userOptions ?? [] as $optionID => $value) {
+ $values[$optionID] = $value;
+ }
+
+ $keys = $placeholders = '';
+ $statementParameters = [$user->userID];
+ foreach ($values as $optionID => $value) {
+ $keys .= ', userOption' . $optionID;
+ $placeholders .= ', ?';
+ $statementParameters[] = $value;
+ }
+
+ $sql = "INSERT INTO wcf1_user_option_value
+ (userID" . $keys . ")
+ VALUES (?" . $placeholders . ")";
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute($statementParameters);
+ }
+
+ private static function getPasswordHash(
+ #[\SensitiveParameter]
+ ?string $password
+ ): string {
+ $manager = PasswordAlgorithmManager::getInstance();
+
+ $algorithm = $manager->getDefaultAlgorithm();
+ if ($password === null) {
+ $algorithm = new InvalidPasswordAlgorithm();
+ $password = '';
+ }
+
+ return $manager->getNameFromAlgorithm($algorithm) . ':' . $algorithm->hash($password);
+ }
+}
diff --git a/wcfsetup/install/files/lib/event/user/UserCreated.class.php b/wcfsetup/install/files/lib/event/user/UserCreated.class.php
new file mode 100644
index 00000000000..f16e2d368c3
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/user/UserCreated.class.php
@@ -0,0 +1,23 @@
+
+ * @since 6.3
+ */
+final class UserCreated implements IPsr14Event
+{
+ public function __construct(
+ public readonly User $user,
+ public readonly UserBuilder $builder,
+ ) {}
+}
diff --git a/wcfsetup/install/files/lib/event/user/UserDeleted.class.php b/wcfsetup/install/files/lib/event/user/UserDeleted.class.php
new file mode 100644
index 00000000000..879fb79aed2
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/user/UserDeleted.class.php
@@ -0,0 +1,21 @@
+
+ * @since 6.3
+ */
+final class UserDeleted implements IPsr14Event
+{
+ public function __construct(
+ public readonly User $user,
+ ) {}
+}
diff --git a/wcfsetup/install/files/lib/event/user/UserRenamed.class.php b/wcfsetup/install/files/lib/event/user/UserRenamed.class.php
new file mode 100644
index 00000000000..644f5c01a07
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/user/UserRenamed.class.php
@@ -0,0 +1,23 @@
+
+ * @since 6.3
+ */
+final class UserRenamed implements IPsr14Event
+{
+ public function __construct(
+ public readonly User $user,
+ public readonly string $oldUsername,
+ ) {}
+}
diff --git a/wcfsetup/install/files/lib/event/user/UserUpdated.class.php b/wcfsetup/install/files/lib/event/user/UserUpdated.class.php
new file mode 100644
index 00000000000..dec6188db88
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/user/UserUpdated.class.php
@@ -0,0 +1,23 @@
+
+ * @since 6.3
+ */
+final class UserUpdated implements IPsr14Event
+{
+ public function __construct(
+ public readonly User $user,
+ public readonly UserBuilder $builder,
+ ) {}
+}
diff --git a/wcfsetup/install/files/lib/form/SettingsForm.class.php b/wcfsetup/install/files/lib/form/SettingsForm.class.php
index 848ea081642..8a62484f454 100644
--- a/wcfsetup/install/files/lib/form/SettingsForm.class.php
+++ b/wcfsetup/install/files/lib/form/SettingsForm.class.php
@@ -2,7 +2,8 @@
namespace wcf\form;
-use wcf\command\user\SetColorScheme;
+use wcf\command\user\UpdateUser;
+use wcf\data\DatabaseObjectBuilder;
use wcf\data\language\Language;
use wcf\data\option\category\OptionCategory;
use wcf\data\style\Style;
@@ -11,7 +12,8 @@
use wcf\data\user\option\category\UserOptionCategory;
use wcf\data\user\option\UserOption;
use wcf\data\user\trophy\UserTrophyList;
-use wcf\data\user\UserAction;
+use wcf\data\user\User;
+use wcf\data\user\UserBuilder;
use wcf\data\user\UserProfile;
use wcf\data\user\UserProfileAction;
use wcf\system\cache\builder\UserOptionCacheBuilder;
@@ -28,7 +30,6 @@
use wcf\system\menu\user\UserMenu;
use wcf\system\request\LinkHandler;
use wcf\system\style\StyleHandler;
-use wcf\system\user\storage\UserStorageHandler;
use wcf\system\WCF;
use wcf\util\StringUtil;
@@ -39,15 +40,20 @@
* @copyright 2001-2026 WoltLab GmbH
* @license GNU Lesser General Public License
*
- * @extends AbstractFormBuilderForm
+ * @extends AbstractDatabaseObjectBuilderForm
*/
-final class SettingsForm extends AbstractFormBuilderForm
+final class SettingsForm extends AbstractDatabaseObjectBuilderForm
{
/**
* @inheritDoc
*/
public $loginRequired = true;
+ /**
+ * @inheritDoc
+ */
+ public string $formAction = 'edit';
+
/**
* option category
*/
@@ -97,6 +103,7 @@ public function readParameters(): void
}
}
+ $this->formObject = new User(WCF::getUser()->userID);
$this->options = $this->getAvailableOptions('settings.' . $this->category);
if ($this->category === 'general') {
@@ -117,6 +124,18 @@ public function readParameters(): void
}
}
+ #[\Override]
+ protected function getDatabaseObjectBuilder(): UserBuilder
+ {
+ return UserBuilder::forUpdate($this->formObject);
+ }
+
+ #[\Override]
+ protected function getCommand(DatabaseObjectBuilder $builder): callable
+ {
+ return new UpdateUser($builder);
+ }
+
#[\Override]
protected function createForm(): void
{
@@ -157,8 +176,10 @@ protected function getGeneralFormContainers(): array
))
->required()
->ignoreInvalidValues()
- ->value(WCF::getUser()->languageID)
->defaultValue(LanguageFactory::getInstance()->getDefaultLanguageID())
+ ->saveValueCallback(static function (UserBuilder $builder, SelectFormField $field) {
+ $builder->setLanguageID((int)$field->getSaveValue());
+ })
);
if ($this->availableContentLanguages !== []) {
@@ -171,7 +192,20 @@ protected function getGeneralFormContainers(): array
$this->availableContentLanguages
))
->ignoreInvalidValues()
- ->value(WCF::getUser()->getLanguageIDs())
+ ->loadValueCallback(static function (User $user, MultipleSelectionFormField $field) {
+ $field->value($user->getLanguageIDs());
+ })
+ ->saveValueCallback(function (UserBuilder $builder, MultipleSelectionFormField $field) {
+ $languageIDs = \array_map(\intval(...), $field->getValue() ?? []);
+ if ($languageIDs === []) {
+ $languageID = (int)$field->getDocument()->getFormField('languageID')?->getSaveValue();
+ if (isset($this->availableContentLanguages[$languageID])) {
+ $languageIDs[] = $languageID;
+ }
+ }
+
+ $builder->setLanguageIDs($languageIDs);
+ })
);
}
@@ -193,8 +227,10 @@ protected function getGeneralFormContainers(): array
->options($styles)
->required()
->ignoreInvalidValues()
- ->value(WCF::getUser()->styleID)
->defaultValue(0)
+ ->saveValueCallback(static function (UserBuilder $builder, SelectFormField $field) {
+ $builder->setStyleID((int)$field->getSaveValue());
+ })
);
}
$styleContainer->appendChild(
@@ -208,8 +244,16 @@ protected function getGeneralFormContainers(): array
])
->required()
->ignoreInvalidValues()
- ->value(WCF::getUser()->getUserOption('colorScheme') ?? 'system')
->defaultValue('system')
+ ->loadValueCallback(static function (User $user, SelectFormField $field) {
+ $field->value($user->getUserOption('colorScheme') ?? 'system');
+ })
+ ->saveValueCallback(static function (UserBuilder $builder, SelectFormField $field) {
+ $optionID = User::getUserOptionID('colorScheme');
+ if ($optionID !== null) {
+ $builder->setUserOption($optionID, (string)$field->getSaveValue());
+ }
+ })
);
$containers[] = $styleContainer;
@@ -238,11 +282,13 @@ static function (MultipleSelectionFormField $formField) use ($maxSpecialTrophies
}
}
))
- ->value(\array_values(\array_unique(\array_map(
- static fn(Trophy $trophy) => $trophy->trophyID,
- (new UserProfile(WCF::getUser()))->getSpecialTrophies()
- ))))
->ignoreInvalidValues()
+ ->loadValueCallback(static function (User $user, MultipleSelectionFormField $field) {
+ $field->value(\array_values(\array_unique(\array_map(
+ static fn(Trophy $trophy) => $trophy->trophyID,
+ (new UserProfile($user))->getSpecialTrophies()
+ ))));
+ })
);
}
@@ -292,10 +338,20 @@ static function (IFormField $formField) use ($option) {
));
}
- $value = WCF::getUser()->{$option->getIdentifier()};
- if ($value !== null) {
- $formField->value($formOption->unserializeValue((string)$value));
- }
+ $formField->loadValueCallback(static function (User $user, IFormField $formField) use ($option, $formOption) {
+ $value = $user->{$option->getIdentifier()};
+ if ($value !== null) {
+ $formField->value($formOption->unserializeValue((string)$value));
+ }
+ });
+ $formField->saveValueCallback(static function (UserBuilder $builder, IFormField $formField) use ($option, $formOption) {
+ $value = $formField->getSaveValue();
+
+ $builder->setUserOption(
+ $option->optionID,
+ $value !== null ? $formOption->serializeValue($value) : $option->defaultValue
+ );
+ });
return $formField;
}
@@ -363,7 +419,7 @@ private function isAccessibleCategory(OptionCategory $category): bool
}
#[\Override]
- protected function setFormAction()
+ protected function setFormAction(): void
{
$parameters = [];
if ($this->category !== 'general') {
@@ -374,82 +430,17 @@ protected function setFormAction()
}
#[\Override]
- public function save(): void
+ protected function afterSave(): void
{
- AbstractForm::save();
-
- $formData = $this->form->getData();
-
- $optionValues = [];
- foreach ($this->options as $options) {
- foreach ($options as $option) {
- $key = $option->getIdentifier();
-
- if (\array_key_exists($key, $formData['data'])) {
- $value = $formData['data'][$key];
- } elseif (\array_key_exists($key, $formData)) {
- // multi-select fields place value at top-level via custom processor, see MultipleSelectionFormField::populate
- $value = $formData[$key];
- } else {
- continue;
- }
-
- $optionValues[$option->optionID] = $value !== null
- ? $option->getFormOption()->serializeValue($value)
- : $option->defaultValue;
- }
+ parent::afterSave();
+
+ $specialTrophies = $this->form->getFormField('specialTrophies');
+ if ($specialTrophies !== null) {
+ $userProfileAction = new UserProfileAction([$this->object->userID], 'updateSpecialTrophies', [
+ 'trophyIDs' => \array_map(\intval(...), $specialTrophies->getValue() ?? []),
+ ]);
+ $userProfileAction->executeAction();
}
-
- $parameters = ['options' => $optionValues];
- if ($this->category === 'general') {
- $parameters['data'] = $this->additionalFields;
-
- if (isset($formData['data']['languageID'])) {
- $parameters['data']['languageID'] = (int)$formData['data']['languageID'];
-
- $contentLanguageIDs = \array_map(\intval(...), $formData['contentLanguageIDs'] ?? []);
- if ($contentLanguageIDs === [] && isset($this->availableContentLanguages[$parameters['data']['languageID']])) {
- $contentLanguageIDs[] = $parameters['data']['languageID'];
- }
- $parameters['languageIDs'] = $contentLanguageIDs;
- }
-
- if (isset($formData['data']['styleID'])) {
- $parameters['data']['styleID'] = (int)$formData['data']['styleID'];
- }
- }
-
- $this->objectAction = new UserAction([WCF::getUser()], 'update', $parameters);
- $this->objectAction->executeAction();
-
- if ($this->category === 'general') {
- // reset user language ids cache
- UserStorageHandler::getInstance()->reset([WCF::getUser()->userID], 'languageIDs');
-
- if ($this->form->getNodeById('specialTrophies') !== null) {
- $userProfileAction = new UserProfileAction([WCF::getUser()->userID], 'updateSpecialTrophies', [
- 'trophyIDs' => \array_map(\intval(...), $formData['specialTrophies'] ?? []),
- ]);
- $userProfileAction->executeAction();
- }
-
- $colorScheme = $formData['data']['colorScheme'] ?? 'system';
- if (WCF::getUser()->getUserOption('colorScheme') !== $colorScheme) {
- new SetColorScheme(WCF::getUser(), $colorScheme)();
- }
- }
-
- $this->saved();
- }
-
- #[\Override]
- public function saved(): void
- {
- AbstractForm::saved();
-
- // The form is intentionally not rebuilt, because `WCF::getUser()` still
- // holds the values from before saving.
- $this->form->showSuccessMessage(true);
}
#[\Override]
From 6039d531c6b1c975cae5e3cdaaf225d9f9660422 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Fri, 2 Oct 2026 14:42:18 +0200
Subject: [PATCH 005/115] Move user deletion cleanup from `UserBuilder` into
`DeleteUser`
---
.../lib/command/user/DeleteUser.class.php | 33 +++++++++++++++++++
.../files/lib/data/user/UserBuilder.class.php | 32 ------------------
2 files changed, 33 insertions(+), 32 deletions(-)
diff --git a/wcfsetup/install/files/lib/command/user/DeleteUser.class.php b/wcfsetup/install/files/lib/command/user/DeleteUser.class.php
index de937fc33ec..4bfa7948d53 100644
--- a/wcfsetup/install/files/lib/command/user/DeleteUser.class.php
+++ b/wcfsetup/install/files/lib/command/user/DeleteUser.class.php
@@ -2,9 +2,14 @@
namespace wcf\command\user;
+use wcf\command\file\DeleteFiles;
+use wcf\data\file\File;
use wcf\data\user\User;
use wcf\data\user\UserBuilder;
use wcf\event\user\UserDeleted;
+use wcf\system\attachment\AttachmentHandler;
+use wcf\system\clipboard\ClipboardHandler;
+use wcf\system\comment\CommentHandler;
use wcf\system\event\EventHandler;
/**
@@ -23,8 +28,36 @@ public function __construct(
public function __invoke(): void
{
+ $this->cleanup($this->user);
+
UserBuilder::delete($this->user);
EventHandler::getInstance()->fire(new UserDeleted($this->user));
}
+
+ private function cleanup(User $user): void
+ {
+ ClipboardHandler::getInstance()->unmark(
+ [$user->userID],
+ ClipboardHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.user')
+ );
+
+ $fileIDs = \array_values(\array_filter([$user->avatarFileID, $user->coverPhotoFileID]));
+ if ($fileIDs !== []) {
+ $files = File::findByIDs($fileIDs);
+ if ($files !== []) {
+ new DeleteFiles(\array_values($files))();
+ }
+ }
+
+ CommentHandler::getInstance()->deleteObjects(
+ 'com.woltlab.wcf.user.profileComment',
+ [$user->userID]
+ );
+
+ AttachmentHandler::removeAttachments(
+ 'com.woltlab.wcf.user.signature',
+ [$user->userID]
+ );
+ }
}
diff --git a/wcfsetup/install/files/lib/data/user/UserBuilder.class.php b/wcfsetup/install/files/lib/data/user/UserBuilder.class.php
index ce23a0bbdbe..228f6142d82 100644
--- a/wcfsetup/install/files/lib/data/user/UserBuilder.class.php
+++ b/wcfsetup/install/files/lib/data/user/UserBuilder.class.php
@@ -3,14 +3,8 @@
namespace wcf\data\user;
use ParagonIE\ConstantTime\Hex;
-use wcf\command\file\DeleteFiles;
use wcf\data\DatabaseObject;
use wcf\data\DatabaseObjectBuilder;
-use wcf\data\file\File;
-use wcf\system\attachment\AttachmentHandler;
-use wcf\system\clipboard\ClipboardHandler;
-use wcf\system\comment\CommentHandler;
-use wcf\system\database\util\PreparedStatementConditionBuilder;
use wcf\system\user\authentication\password\algorithm\Invalid as InvalidPasswordAlgorithm;
use wcf\system\user\authentication\password\PasswordAlgorithmManager;
use wcf\system\WCF;
@@ -334,32 +328,6 @@ protected function afterUpdate(DatabaseObject $object): void
}
}
- #[\Override]
- protected static function beforeDeleteAll(array $objectIDs): void
- {
- ClipboardHandler::getInstance()->unmark(
- $objectIDs,
- ClipboardHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.user')
- );
-
- $conditionBuilder = new PreparedStatementConditionBuilder();
- $conditionBuilder->add('userID IN (?)', [$objectIDs]);
- $conditionBuilder->add('avatarFileID IS NOT NULL');
-
- $sql = "SELECT avatarFileID
- FROM wcf1_user
- {$conditionBuilder}";
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute($conditionBuilder->getParameters());
- $avatarFiles = File::findByIDs($statement->fetchAll(\PDO::FETCH_COLUMN));
- if ($avatarFiles !== []) {
- new DeleteFiles($avatarFiles)();
- }
-
- CommentHandler::getInstance()->deleteObjects('com.woltlab.wcf.user.profileComment', $objectIDs);
- AttachmentHandler::removeAttachments('com.woltlab.wcf.user.signature', $objectIDs);
- }
-
/**
* Inserts the row with the user option values, using the default value of
* the options that have not been set explicitly.
From ca8e7b1a6ef1caec254e091f474dc836e91f383d Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Fri, 2 Oct 2026 15:37:59 +0200
Subject: [PATCH 006/115] Migrate `RegisterForm` to form builder
---
com.woltlab.wcf/templates/register.tpl | 253 +-----
phpstan-baseline.neon | 6 -
.../files/lib/data/user/UserBuilder.class.php | 30 +-
.../lib/data/user/option/UserOption.class.php | 2 +
.../files/lib/form/RegisterForm.class.php | 719 ++++++++++--------
.../files/lib/form/SettingsForm.class.php | 147 +---
.../user/TUserOptionFormFields.class.php | 178 +++++
.../lib/util/UserRegistrationUtil.class.php | 2 +
8 files changed, 628 insertions(+), 709 deletions(-)
create mode 100644 wcfsetup/install/files/lib/system/form/option/user/TUserOptionFormFields.class.php
diff --git a/com.woltlab.wcf/templates/register.tpl b/com.woltlab.wcf/templates/register.tpl
index e6831cc5d1e..3c7f791a31c 100644
--- a/com.woltlab.wcf/templates/register.tpl
+++ b/com.woltlab.wcf/templates/register.tpl
@@ -6,236 +6,29 @@
{lang}wcf.user.3rdparty.{$__wcf->session->getVar('__3rdPartyProvider')}.register{/lang}
{/if}
-{include file='shared_formError'}
-
-
-
-
- *
- {lang}wcf.global.form.required{/lang}
-
-
-
+
+{/if}
{include file='authFlowFooter'}
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
index 64fa3f6f964..299a0464995 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -763,12 +763,6 @@ parameters:
count: 1
path: wcfsetup/install/files/lib/form/NotificationUnsubscribeForm.class.php
- -
- message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
- identifier: empty.notAllowed
- count: 4
- path: wcfsetup/install/files/lib/form/RegisterForm.class.php
-
-
message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
identifier: empty.notAllowed
diff --git a/wcfsetup/install/files/lib/data/user/UserBuilder.class.php b/wcfsetup/install/files/lib/data/user/UserBuilder.class.php
index 228f6142d82..6135e7cdb98 100644
--- a/wcfsetup/install/files/lib/data/user/UserBuilder.class.php
+++ b/wcfsetup/install/files/lib/data/user/UserBuilder.class.php
@@ -230,7 +230,8 @@ public function setUserOption(int $optionID, ?string $value): static
/**
* Sets the user groups of the user, replacing the existing ones. New users
- * are always added to the default groups `EVERYONE` and `USERS`.
+ * are added to the default groups `EVERYONE` and `USERS` unless the user
+ * groups are set explicitly.
*
* @param list $groupIDs
*/
@@ -283,6 +284,20 @@ public function isPasswordChanged(): bool
return $this->passwordChanged;
}
+ /**
+ * Sets the matches of the spam check during the registration.
+ *
+ * @param string[] $blacklistMatches
+ */
+ public function setBlacklistMatches(array $blacklistMatches): static
+ {
+ $this->properties['blacklistMatches'] = $blacklistMatches !== []
+ ? \json_encode($blacklistMatches, \JSON_THROW_ON_ERROR)
+ : '';
+
+ return $this;
+ }
+
#[\Override]
protected function getRequiredProperties(): array
{
@@ -295,7 +310,11 @@ protected function afterCreate(DatabaseObject $object): void
$this->createUserOptions($object);
$editor = new UserEditor($object);
- $editor->addToGroups($this->groupIDs ?? [], false, true);
+ if ($this->groupIDs === null) {
+ $editor->addToGroups([], false, true);
+ } else {
+ $editor->addToGroups($this->groupIDs, false, false);
+ }
$editor->addToLanguages($this->languageIDs ?? [], false);
if (\PACKAGE_ID !== 0) {
@@ -342,7 +361,12 @@ private function createUserOptions(User $user): void
$values = $statement->fetchMap('optionID', 'defaultValue');
foreach ($this->userOptions ?? [] as $optionID => $value) {
- $values[$optionID] = $value;
+ if ($value === null) {
+ // Omit the column to fall back to its default value.
+ unset($values[$optionID]);
+ } else {
+ $values[$optionID] = $value;
+ }
}
$keys = $placeholders = '';
diff --git a/wcfsetup/install/files/lib/data/user/option/UserOption.class.php b/wcfsetup/install/files/lib/data/user/option/UserOption.class.php
index 477fd9bb93e..c016fe9c678 100644
--- a/wcfsetup/install/files/lib/data/user/option/UserOption.class.php
+++ b/wcfsetup/install/files/lib/data/user/option/UserOption.class.php
@@ -115,7 +115,9 @@ class UserOption extends Option implements ITitledObject
* @var array
*/
private const FORM_OPTION_TYPES = [
+ 'birthday' => 'date',
'boolean' => 'boolean',
+ 'date' => 'date',
'float' => 'float',
'integer' => 'integer',
'radioButton' => 'radioButton',
diff --git a/wcfsetup/install/files/lib/form/RegisterForm.class.php b/wcfsetup/install/files/lib/form/RegisterForm.class.php
index 3e9971a6c48..0efee8c4a4d 100644
--- a/wcfsetup/install/files/lib/form/RegisterForm.class.php
+++ b/wcfsetup/install/files/lib/form/RegisterForm.class.php
@@ -3,15 +3,17 @@
namespace wcf\form;
use ParagonIE\ConstantTime\Hex;
-use wcf\acp\form\UserAddForm;
use wcf\action\EmailValidationAction;
use wcf\action\UsernameValidationAction;
-use wcf\data\object\type\ObjectType;
+use wcf\command\user\CreateRegistrationNotification;
+use wcf\command\user\CreateUser;
+use wcf\data\DatabaseObjectBuilder;
+use wcf\data\language\Language;
use wcf\data\user\group\UserGroup;
+use wcf\data\user\option\UserOption;
use wcf\data\user\User;
-use wcf\data\user\UserAction;
+use wcf\data\user\UserBuilder;
use wcf\event\user\RegistrationSpamChecking;
-use wcf\system\captcha\CaptchaHandler;
use wcf\system\email\Email;
use wcf\system\email\mime\MimePartFacade;
use wcf\system\email\mime\RecipientAwareTextMimePart;
@@ -19,14 +21,27 @@
use wcf\system\event\EventHandler;
use wcf\system\exception\NamedUserException;
use wcf\system\exception\PermissionDeniedException;
-use wcf\system\exception\SystemException;
use wcf\system\exception\UserInputException;
+use wcf\system\form\builder\container\FormContainer;
+use wcf\system\form\builder\field\AbstractFormField;
+use wcf\system\form\builder\field\BooleanFormField;
+use wcf\system\form\builder\field\CaptchaFormField;
+use wcf\system\form\builder\field\EmailFormField;
+use wcf\system\form\builder\field\IFormField;
+use wcf\system\form\builder\field\MultipleSelectionFormField;
+use wcf\system\form\builder\field\PasswordFormField;
+use wcf\system\form\builder\field\SelectFormField;
+use wcf\system\form\builder\field\user\UsernameFormField;
+use wcf\system\form\builder\field\validation\FormFieldValidationError;
+use wcf\system\form\builder\field\validation\FormFieldValidator;
+use wcf\system\form\option\user\TUserOptionFormFields;
+use wcf\system\language\LanguageFactory;
use wcf\system\request\LinkHandler;
use wcf\system\user\authentication\configuration\UserAuthenticationConfigurationFactory;
use wcf\system\user\authentication\LoginRedirect;
-use wcf\command\user\CreateRegistrationNotification;
use wcf\system\user\group\assignment\UserGroupAssignmentHandler;
use wcf\system\WCF;
+use wcf\util\DateUtil;
use wcf\util\HeaderUtil;
use wcf\util\HtmlString;
use wcf\util\StringUtil;
@@ -36,89 +51,53 @@
/**
* Shows the user registration form.
*
- * @author Marcel Werk
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License
+ * @author Marcel Werk
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ *
+ * @extends AbstractDatabaseObjectBuilderForm
*/
-class RegisterForm extends UserAddForm
+final class RegisterForm extends AbstractDatabaseObjectBuilderForm
{
- /**
- * true if external authentication is used
- * @var bool
- */
- public $isExternalAuthentication = false;
-
- /**
- * @inheritDoc
- */
- public $neededPermissions = [];
-
- /**
- * holds a language variable with information about the registration process
- * e.g. if you need to activate your account
- * @var string
- */
- public $message = '';
-
- /**
- * captcha object type object
- * @var ?ObjectType
- */
- public $captchaObjectType;
-
- /**
- * name of the captcha object type; if empty, captcha is disabled
- * @var string
- */
- public $captchaObjectTypeName = \CAPTCHA_TYPE;
-
- /**
- * true if captcha is used
- * @var bool
- */
- public $useCaptcha = \REGISTER_USE_CAPTCHA !== 0;
+ use TUserOptionFormFields {
+ getUserOptionFormField as getDefaultUserOptionFormField;
+ }
/**
- * field names
- * @var array
+ * min number of seconds between form request and submit
*/
- public $randomFieldNames = [];
+ public static int $minRegistrationTime = 3;
/**
- * list of fields that have matches in the blacklist
- * @var string[]
- * @since 5.2
- * @deprecated 6.1
+ * true if external authentication is used
*/
- public $blacklistMatches = [];
+ public bool $isExternalAuthentication = false;
/**
- * min number of seconds between form request and submit
- * @var int
+ * name of the language item with information about the registration process,
+ * e.g. if the account needs to be activated
*/
- public static $minRegistrationTime = 3;
+ public string $message = '';
/**
- * @var mixed[]
+ * true if the user registers with the email address provided by the external authentication
*/
- public $passwordStrengthVerdict = [];
+ private bool $registerVia3rdParty = false;
/**
- * @since 6.1
+ * options that are asked during the registration grouped by the name of their category
+ * @var array
*/
- public bool $termsConfirmed = false;
+ private array $options = [];
- /**
- * @since 6.1
- */
private RegistrationSpamChecking $spamCheckEvent;
#[\Override]
- public function readParameters()
+ public function readParameters(): void
{
parent::readParameters();
- if (!empty($_REQUEST['url'])) {
+ if (isset($_REQUEST['url']) && $_REQUEST['url'] !== '') {
LoginRedirect::setUrl(StringUtil::trim($_REQUEST['url']));
}
@@ -137,361 +116,435 @@ public function readParameters()
if (WCF::getSession()->getVar('__3rdPartyProvider') !== null) {
$this->isExternalAuthentication = true;
}
+
+ $this->options = $this->getUserOptionsByCategory(
+ 'profile',
+ $this->isAskedDuringRegistration(...)
+ );
+
+ if ($_POST === []) {
+ WCF::getSession()->register('registrationStartTime', \TIME_NOW);
+ }
+ }
+
+ /**
+ * Returns true if the given option is asked during the registration.
+ */
+ private function isAskedDuringRegistration(UserOption $option): bool
+ {
+ if (!$option->validateOptions() || !$option->validatePermissions() || !$option->isEditable(true)) {
+ return false;
+ }
+
+ return $option->askDuringRegistration !== 0
+ || $option->required !== 0
+ || ($option->editable & UserOption::EDITABILITY_OWNER_DURING_REGISTRATION) !== 0
+ || $this->isBirthdayWithMinimumAge($option);
+ }
+
+ private function isBirthdayWithMinimumAge(UserOption $option): bool
+ {
+ return $option->optionName === 'birthday' && \REGISTER_MIN_USER_AGE !== 0;
}
#[\Override]
- public function readFormParameters()
+ protected function getDatabaseObjectBuilder(): UserBuilder
{
- parent::readFormParameters();
+ $builder = UserBuilder::forCreate()
+ ->setRegistrationDate(\TIME_NOW)
+ ->setLanguageID(WCF::getLanguage()->languageID)
+ ->setSignatureEnableHtml(true);
- if (!empty($this->username) || !empty($this->email)) {
- throw new PermissionDeniedException();
+ if (\LOG_IP_ADDRESS !== 0) {
+ $builder->setRegistrationIpAddress(UserUtil::getIpAddress());
}
- $this->randomFieldNames = WCF::getSession()->getVar('registrationRandomFieldNames');
- if ($this->randomFieldNames === null) {
- throw new PermissionDeniedException();
- }
+ if ($this->isExternalAuthentication) {
+ $provider = WCF::getSession()->getVar('__3rdPartyProvider');
+ switch ($provider) {
+ case 'github':
+ case 'facebook':
+ case 'google':
+ case 'twitter':
+ if (($oauthUser = WCF::getSession()->getVar('__oauthUser')) !== null) {
+ $builder->setAuthData($provider . ':' . $oauthUser->getId());
+ }
+ break;
+ }
- if (isset($_POST[$this->randomFieldNames['username']])) {
- $this->username = StringUtil::trim($_POST[$this->randomFieldNames['username']]);
- }
- if (isset($_POST[$this->randomFieldNames['email']])) {
- $this->email = StringUtil::trim($_POST[$this->randomFieldNames['email']]);
+ // Accounts connected to a 3rdParty login do not have passwords.
+ $builder->setPassword(null);
+
+ $email = WCF::getSession()->getVar('__email');
+ if ($email !== null && $email === $this->form->getFormField('email')?->getSaveValue()) {
+ $this->registerVia3rdParty = true;
+ }
}
- if (isset($_POST[$this->randomFieldNames['password']])) {
- $this->password = $_POST[$this->randomFieldNames['password']];
+
+ if ($this->spamCheckEvent->hasMatches()) {
+ $builder->setBlacklistMatches($this->spamCheckEvent->getMatches());
}
- if (isset($_POST[$this->randomFieldNames['password'] . '_passwordStrengthVerdict'])) {
- try {
- $this->passwordStrengthVerdict = \json_decode(
- $_POST[$this->randomFieldNames['password'] . '_passwordStrengthVerdict'],
- true,
- flags: \JSON_THROW_ON_ERROR
- );
- } catch (\JsonException) {
- // ignore
+
+ if ($this->requiresActivation()) {
+ $builder->setActivationCode(UserRegistrationUtil::getActivationCode());
+ if (!$this->registerVia3rdParty) {
+ $builder->setEmailConfirmed(Hex::encode(\random_bytes(20)));
}
- }
- if (!empty($_POST['termsConfirmed'])) {
- $this->termsConfirmed = true;
+ $builder->setGroupIDs(UserGroup::getGroupIDsByType([UserGroup::EVERYONE, UserGroup::GUESTS]));
}
- $this->groupIDs = [];
+ return $builder;
+ }
- if ($this->captchaObjectType !== null) {
- $this->captchaObjectType->getProcessor()->readFormParameters();
- }
+ private function requiresActivation(): bool
+ {
+ return $this->spamCheckEvent->hasMatches()
+ || (
+ ((int)\REGISTER_ACTIVATION_METHOD & User::REGISTER_ACTIVATION_USER) !== 0
+ && !$this->registerVia3rdParty
+ )
+ || ((int)\REGISTER_ACTIVATION_METHOD & User::REGISTER_ACTIVATION_ADMIN) !== 0;
}
#[\Override]
- protected function initOptionHandler()
+ protected function getCommand(DatabaseObjectBuilder $builder): callable
{
- $this->optionHandler->setInRegistration();
- parent::initOptionHandler();
+ return new CreateUser($builder);
}
#[\Override]
- public function validate()
+ protected function createForm(): void
{
- // validate captcha first
- $this->validateCaptcha();
+ parent::createForm();
- if (\REGISTER_ENABLE_DISCLAIMER !== 0 && !$this->termsConfirmed) {
- $this->errorType['termsConfirmed'] = 'empty';
- }
-
- parent::validate();
+ $this->form->appendChild($this->getGeneralFormContainer());
+ $this->form->appendChildren($this->getUserOptionFormContainers($this->options));
- // validate registration time
if (
- !$this->isExternalAuthentication
- && (
- WCF::getSession()->getVar('registrationStartTime') === null
- || (\TIME_NOW - WCF::getSession()->getVar('registrationStartTime')) < self::$minRegistrationTime
- )
+ \REGISTER_USE_CAPTCHA !== 0
+ && WCF::getSession()->getVar('noRegistrationCaptcha') !== true
) {
- throw new UserInputException('registrationStartTime', []);
- }
-
- $this->spamCheckEvent = new RegistrationSpamChecking($this->username, $this->email, UserUtil::getIpAddress());
- EventHandler::getInstance()->fire($this->spamCheckEvent);
- if ($this->spamCheckEvent->hasMatches() && \REGISTER_ANTISPAM_ACTION === 'block') {
- throw new NamedUserException(HtmlString::fromSafeHtml(
- WCF::getLanguage()->getDynamicVariable('wcf.user.register.error.blacklistMatches')
- ));
+ $this->form->appendChild(
+ CaptchaFormField::create()
+ ->objectType(\CAPTCHA_TYPE)
+ );
}
}
#[\Override]
- public function readData()
+ protected function finalizeForm(): void
{
- if ($this->useCaptcha && $this->captchaObjectTypeName !== '') {
- $this->captchaObjectType = CaptchaHandler::getInstance()->getObjectTypeByName($this->captchaObjectTypeName);
- if ($this->captchaObjectType === null) {
- throw new SystemException("Unknown captcha object type with id '" . $this->captchaObjectTypeName . "'");
- }
+ parent::finalizeForm();
- if (!$this->captchaObjectType->getProcessor()->isAvailable()) {
- $this->captchaObjectType = null;
- }
-
- if (WCF::getSession()->getVar('noRegistrationCaptcha') === true) {
- $this->captchaObjectType = null;
- }
- }
+ $this->form->getButton('submitButton')->label('wcf.user.button.register');
+ }
- parent::readData();
+ private function getGeneralFormContainer(): FormContainer
+ {
+ $container = FormContainer::create('general')
+ ->appendChildren([
+ UsernameFormField::create()
+ ->required()
+ ->minimumLength(\REGISTER_USERNAME_MIN_LENGTH)
+ ->maximumLength(\REGISTER_USERNAME_MAX_LENGTH)
+ ->autoComplete('username')
+ ->fieldAttribute(
+ 'data-validation-endpoint',
+ LinkHandler::getInstance()->getControllerLink(UsernameValidationAction::class)
+ )
+ ->description('wcf.user.username.description')
+ ->value(WCF::getSession()->getVar('__username') ?? '')
+ ->addValidator(new FormFieldValidator(
+ 'registrationUtil',
+ static function (UsernameFormField $formField) {
+ if (!UserRegistrationUtil::isValidUsername((string)$formField->getValue())) {
+ $formField->addValidationError(new FormFieldValidationError(
+ 'invalid',
+ 'wcf.user.username.error.invalid'
+ ));
+ }
+ }
+ ))
+ ->saveValueCallback(static function (UserBuilder $builder, UsernameFormField $field) {
+ $builder->setUsername($field->getSaveValue());
+ }),
+ EmailFormField::create('email')
+ ->label('wcf.user.email')
+ ->required()
+ ->autoComplete('email')
+ ->fieldAttribute(
+ 'data-validation-endpoint',
+ LinkHandler::getInstance()->getControllerLink(EmailValidationAction::class)
+ )
+ ->value(WCF::getSession()->getVar('__email') ?? '')
+ ->addValidator(new FormFieldValidator(
+ 'registrationUtil',
+ static function (EmailFormField $formField) {
+ $email = (string)$formField->getValue();
+ if (!UserRegistrationUtil::isValidEmail($email)) {
+ $formField->addValidationError(new FormFieldValidationError(
+ 'invalid',
+ 'wcf.user.email.error.invalid'
+ ));
+ } elseif (!User::getUserByEmail($email)->isGuest()) {
+ $formField->addValidationError(new FormFieldValidationError(
+ 'notUnique',
+ 'wcf.user.email.error.notUnique'
+ ));
+ }
+ }
+ ))
+ ->saveValueCallback(static function (UserBuilder $builder, EmailFormField $field) {
+ $builder->setEmail($field->getSaveValue());
+ }),
+ ]);
- if ($_POST === []) {
- $this->languageID = WCF::getLanguage()->languageID;
+ if (!$this->isExternalAuthentication) {
+ $container->appendChild(
+ PasswordFormField::create('password')
+ ->label('wcf.user.password')
+ ->description('wcf.user.password.description')
+ ->addClass('passwordFormField--stacked')
+ ->required()
+ ->autoComplete('new-password')
+ ->fieldAttribute('passwordrules', UserRegistrationUtil::getPasswordRulesAttributeValue())
+ ->passwordStrengthMeter()
+ ->addMeterRelatedFieldId('username')
+ ->addMeterRelatedFieldId('email')
+ ->addValidator(new FormFieldValidator('passwordStrength', $this->validatePasswordStrength(...)))
+ ->saveValueCallback(static function (UserBuilder $builder, PasswordFormField $field) {
+ $builder->setPassword($field->getSaveValue());
+ })
+ );
+ }
- if (WCF::getSession()->getVar('__username') !== null) {
- $this->username = WCF::getSession()->getVar('__username');
- }
- if (WCF::getSession()->getVar('__email') !== null) {
- $this->email = WCF::getSession()->getVar('__email');
+ $availableLanguages = LanguageFactory::getInstance()->getLanguages();
+ if (\count($availableLanguages) > 1) {
+ $availableContentLanguages = LanguageFactory::getInstance()->getContentLanguages();
+
+ $container->appendChild(
+ SelectFormField::create('languageID')
+ ->label('wcf.user.language')
+ ->description('wcf.user.language.description')
+ ->options(\array_map(
+ static fn(Language $language) => StringUtil::encodeHTML($language->languageName),
+ $availableLanguages
+ ))
+ ->required()
+ ->ignoreInvalidValues()
+ ->defaultValue(LanguageFactory::getInstance()->getDefaultLanguageID())
+ ->value(WCF::getLanguage()->languageID)
+ ->saveValueCallback(static function (UserBuilder $builder, SelectFormField $field) {
+ $builder->setLanguageID((int)$field->getSaveValue());
+ })
+ );
+
+ if ($availableContentLanguages !== []) {
+ $container->appendChild(
+ MultipleSelectionFormField::create('visibleLanguages')
+ ->label('wcf.user.visibleLanguages')
+ ->description('wcf.user.visibleLanguages.description')
+ ->options(\array_map(
+ static fn(Language $language) => StringUtil::encodeHTML($language->languageName),
+ $availableContentLanguages
+ ))
+ ->ignoreInvalidValues()
+ ->saveValueCallback(
+ static function (UserBuilder $builder, MultipleSelectionFormField $field) use ($availableContentLanguages) {
+ $languageIDs = \array_map(\intval(...), $field->getValue() ?? []);
+ if ($languageIDs === []) {
+ $languageID = (int)$field->getDocument()->getFormField('languageID')?->getSaveValue();
+ if (isset($availableContentLanguages[$languageID])) {
+ $languageIDs[] = $languageID;
+ }
+ }
+
+ $builder->setLanguageIDs($languageIDs);
+ }
+ )
+ );
}
+ }
- WCF::getSession()->register('registrationStartTime', \TIME_NOW);
-
- // generate random field names
- $this->randomFieldNames = [
- 'username' => UserRegistrationUtil::getRandomFieldName('username'),
- 'email' => UserRegistrationUtil::getRandomFieldName('email'),
- 'password' => UserRegistrationUtil::getRandomFieldName('password'),
- ];
-
- WCF::getSession()->register('registrationRandomFieldNames', $this->randomFieldNames);
+ if (\REGISTER_ENABLE_DISCLAIMER !== 0) {
+ $container->appendChild(
+ BooleanFormField::create('termsConfirmed')
+ ->label('wcf.user.register.confirmTerms')
+ ->required()
+ );
}
+
+ return $container;
}
/**
- * Reads option tree on page init.
+ * Returns the form field for the given user option, enforcing the minimum
+ * age for the birthday.
*/
- #[\Override]
- protected function readOptionTree()
- {
- $this->optionTree = $this->optionHandler->getOptionTree('profile');
- }
-
- #[\Override]
- public function assignVariables()
+ protected function getUserOptionFormField(UserOption $option): AbstractFormField
{
- parent::assignVariables();
-
- WCF::getTPL()->assign([
- 'captchaObjectType' => $this->captchaObjectType,
- 'isExternalAuthentication' => $this->isExternalAuthentication,
- 'randomFieldNames' => $this->randomFieldNames,
- 'passwordRulesAttributeValue' => UserRegistrationUtil::getPasswordRulesAttributeValue(),
- 'usernameValidationEndpoint' => LinkHandler::getInstance()->getControllerLink(UsernameValidationAction::class),
- 'emailValidationEndpoint' => LinkHandler::getInstance()->getControllerLink(EmailValidationAction::class),
- 'termsConfirmed' => $this->termsConfirmed,
- ]);
- }
+ $formField = $this->getDefaultUserOptionFormField($option);
+
+ if ($this->isBirthdayWithMinimumAge($option)) {
+ $formField->required();
+ $formField->addValidator(new FormFieldValidator(
+ 'minimumAge',
+ static function (IFormField $formField) {
+ $value = $formField->getSaveValue();
+ if ($value !== null && $value !== '' && DateUtil::getAge($value) < \REGISTER_MIN_USER_AGE) {
+ $formField->addValidationError(new FormFieldValidationError(
+ 'birthdayTooYoung',
+ 'wcf.user.option.error.birthdayTooYoung'
+ ));
+ }
+ }
+ ));
+ }
- #[\Override]
- public function show()
- {
- AbstractForm::show();
+ return $formField;
}
- /**
- * @return void
- */
- protected function validateCaptcha()
+ private function validatePasswordStrength(PasswordFormField $formField): void
{
- if ($this->captchaObjectType !== null) {
+ $verdict = [];
+ if (isset($_POST['password_passwordStrengthVerdict'])) {
try {
- $this->captchaObjectType->getProcessor()->validate();
- } catch (UserInputException $e) {
- $this->errorType[$e->getField()] = $e->getType();
+ $verdict = \json_decode($_POST['password_passwordStrengthVerdict'], true, flags: \JSON_THROW_ON_ERROR);
+ } catch (\JsonException) {
+ // ignore
}
}
+
+ if (($verdict['score'] ?? 4) < \PASSWORD_MIN_SCORE) {
+ $formField->addValidationError(new FormFieldValidationError(
+ 'notSecure',
+ 'wcf.user.password.error.notSecure'
+ ));
+ }
}
#[\Override]
- protected function validateUsername(string $username)
+ public function validate(): void
{
- parent::validateUsername($username);
+ parent::validate();
- // check for min-max length
- if (!UserRegistrationUtil::isValidUsername($username)) {
- throw new UserInputException('username', 'invalid');
- }
- }
+ // validate registration time
+ if (
+ !$this->isExternalAuthentication
+ && (
+ WCF::getSession()->getVar('registrationStartTime') === null
+ || (\TIME_NOW - WCF::getSession()->getVar('registrationStartTime')) < self::$minRegistrationTime
+ )
+ ) {
+ $this->form->invalid();
- #[\Override]
- protected function validatePassword(
- #[\SensitiveParameter]
- string $password
- ): void {
- if (!$this->isExternalAuthentication) {
- parent::validatePassword($password);
+ throw new UserInputException($this->form->getPrefixedId());
+ }
- // check security of the given password
- if (($this->passwordStrengthVerdict['score'] ?? 4) < \PASSWORD_MIN_SCORE) {
- throw new UserInputException('password', 'notSecure');
- }
+ $this->spamCheckEvent = new RegistrationSpamChecking(
+ $this->form->getFormField('username')->getSaveValue(),
+ $this->form->getFormField('email')->getSaveValue(),
+ UserUtil::getIpAddress()
+ );
+ EventHandler::getInstance()->fire($this->spamCheckEvent);
+ if ($this->spamCheckEvent->hasMatches() && \REGISTER_ANTISPAM_ACTION === 'block') {
+ throw new NamedUserException(HtmlString::fromSafeHtml(
+ WCF::getLanguage()->getDynamicVariable('wcf.user.register.error.blacklistMatches')
+ ));
}
}
#[\Override]
- protected function validateEmail(string $email): void
+ public function saved(): void
{
- parent::validateEmail($email);
+ $user = $this->object;
+ \assert($user instanceof User);
- if (!UserRegistrationUtil::isValidEmail($email)) {
- throw new UserInputException('email', 'invalid');
- }
+ WCF::getSession()->changeUser($user);
+
+ $this->message = $this->handleActivation($user);
+
+ new CreateRegistrationNotification($user)();
+
+ // The form is not rebuilt, because the user is redirected after the registration.
+ AbstractForm::saved();
}
#[\Override]
- public function save()
+ protected function afterSave(): void
{
- AbstractForm::save();
-
- // get options
- $saveOptions = $this->optionHandler->save();
- $registerVia3rdParty = false;
+ parent::afterSave();
- if ($this->isExternalAuthentication) {
- $provider = WCF::getSession()->getVar('__3rdPartyProvider');
- switch ($provider) {
- case 'github':
- case 'facebook':
- case 'google':
- case 'twitter':
- if (($oauthUser = WCF::getSession()->getVar('__oauthUser')) !== null) {
- $this->additionalFields['authData'] = $provider . ':' . $oauthUser->getId();
- }
- break;
- }
+ $this->form->cleanup();
+ WCF::getSession()->unregister('noRegistrationCaptcha');
+ WCF::getSession()->unregister('registrationStartTime');
- // Accounts connected to a 3rdParty login do not have passwords.
- $this->password = null;
+ HeaderUtil::delayedRedirect(
+ LoginRedirect::getUrl(),
+ WCF::getLanguage()->getDynamicVariable($this->message, ['user' => $this->object]),
+ 15,
+ 'success',
+ true
+ );
- if (WCF::getSession()->getVar('__email') !== null && WCF::getSession()->getVar('__email') === $this->email) {
- $registerVia3rdParty = true;
- }
- }
+ exit;
+ }
- $eventParameters = [
- 'saveOptions' => $saveOptions,
- 'registerVia3rdParty' => $registerVia3rdParty,
- ];
- EventHandler::getInstance()->fireAction($this, 'registerVia3rdParty', $eventParameters);
- $saveOptions = $eventParameters['saveOptions'];
- $registerVia3rdParty = $eventParameters['registerVia3rdParty'];
+ /**
+ * Handles the activation of the new user and returns the name of the
+ * language item with the message that is shown to the user.
+ */
+ private function handleActivation(User $user): string
+ {
+ if ((int)\REGISTER_ACTIVATION_METHOD === User::REGISTER_ACTIVATION_NONE && !$this->spamCheckEvent->hasMatches()) {
+ UserGroupAssignmentHandler::getInstance()->checkUsers([$user->userID]);
- $this->additionalFields['languageID'] = $this->languageID;
- if (\LOG_IP_ADDRESS !== 0) {
- $this->additionalFields['registrationIpAddress'] = UserUtil::getIpAddress();
+ return 'wcf.user.register.success';
}
- // generate activation code
- $addDefaultGroups = true;
if (
- $this->spamCheckEvent->hasMatches()
- || (
- ((int)\REGISTER_ACTIVATION_METHOD & User::REGISTER_ACTIVATION_USER) !== 0
- && $registerVia3rdParty !== true
- )
- || ((int)\REGISTER_ACTIVATION_METHOD & User::REGISTER_ACTIVATION_ADMIN) !== 0
- ) {
- $activationCode = UserRegistrationUtil::getActivationCode();
- $this->additionalFields['activationCode'] = $activationCode;
- if ($registerVia3rdParty !== true) {
- $emailConfirmCode = Hex::encode(\random_bytes(20));
- $this->additionalFields['emailConfirmed'] = $emailConfirmCode;
- }
- $addDefaultGroups = false;
- $this->groupIDs = UserGroup::getGroupIDsByType([UserGroup::EVERYONE, UserGroup::GUESTS]);
- }
-
- // create user
- $data = [
- 'data' => \array_merge($this->additionalFields, [
- 'username' => $this->username,
- 'email' => $this->email,
- 'password' => $this->password,
- 'blacklistMatches' => $this->spamCheckEvent->hasMatches() ? \json_encode($this->spamCheckEvent->getMatches(), \JSON_THROW_ON_ERROR) : '',
- 'signatureEnableHtml' => 1,
- ]),
- 'groups' => $this->groupIDs,
- 'languageIDs' => $this->visibleLanguages,
- 'options' => $saveOptions,
- 'addDefaultGroups' => $addDefaultGroups,
- ];
- $this->objectAction = new UserAction([], 'create', $data);
- $result = $this->objectAction->executeAction();
- /** @var User $user */
- $user = $result['returnValues'];
-
- // update session
- WCF::getSession()->changeUser($user);
-
- // activation management
- if ((int)\REGISTER_ACTIVATION_METHOD === User::REGISTER_ACTIVATION_NONE && !$this->spamCheckEvent->hasMatches()) {
- $this->message = 'wcf.user.register.success';
-
- UserGroupAssignmentHandler::getInstance()->checkUsers([$user->userID]);
- } elseif (
((int)\REGISTER_ACTIVATION_METHOD & User::REGISTER_ACTIVATION_USER) !== 0
&& !$this->spamCheckEvent->hasMatches()
) {
// registering via 3rdParty leads to instant activation
- if ($registerVia3rdParty === true) {
+ if ($this->registerVia3rdParty) {
if (((int)\REGISTER_ACTIVATION_METHOD & User::REGISTER_ACTIVATION_ADMIN) !== 0) {
- $this->message = 'wcf.user.register.success.awaitActivation';
- } else {
- $this->message = 'wcf.user.register.success';
+ return 'wcf.user.register.success.awaitActivation';
}
- } else {
- $email = new Email();
- $email->addRecipient(new UserMailbox(WCF::getUser()));
- $email->setSubject(
- WCF::getLanguage()->getDynamicVariable('wcf.user.register.needActivation.mail.subject')
- );
- $email->setBody(new MimePartFacade([
- new RecipientAwareTextMimePart('text/html', 'email_registerNeedActivation'),
- new RecipientAwareTextMimePart('text/plain', 'email_registerNeedActivation'),
- ]));
- $email->send();
- $this->message = 'wcf.user.register.success.needActivation';
- }
- } elseif (
- ((int)\REGISTER_ACTIVATION_METHOD & User::REGISTER_ACTIVATION_ADMIN) !== 0
- || $this->spamCheckEvent->hasMatches()
- ) {
- $this->message = 'wcf.user.register.success.awaitActivation';
- }
- new CreateRegistrationNotification($user)();
+ return 'wcf.user.register.success';
+ }
- if ($this->captchaObjectType !== null) {
- $this->captchaObjectType->getProcessor()->reset();
+ $email = new Email();
+ $email->addRecipient(new UserMailbox($user));
+ $email->setSubject(
+ WCF::getLanguage()->getDynamicVariable('wcf.user.register.needActivation.mail.subject')
+ );
+ $email->setBody(new MimePartFacade([
+ new RecipientAwareTextMimePart('text/html', 'email_registerNeedActivation'),
+ new RecipientAwareTextMimePart('text/plain', 'email_registerNeedActivation'),
+ ]));
+ $email->send();
+
+ return 'wcf.user.register.success.needActivation';
}
- if (WCF::getSession()->getVar('noRegistrationCaptcha') === true) {
- WCF::getSession()->unregister('noRegistrationCaptcha');
- }
+ return 'wcf.user.register.success.awaitActivation';
+ }
- // login user
- WCF::getSession()->unregister('registrationRandomFieldNames');
- WCF::getSession()->unregister('registrationStartTime');
- $this->saved();
+ #[\Override]
+ protected function setFormAction(): void
+ {
+ $this->form->action(LinkHandler::getInstance()->getControllerLink(static::class));
+ }
- // forward to index page
- HeaderUtil::delayedRedirect(
- LoginRedirect::getUrl(),
- WCF::getLanguage()->getDynamicVariable($this->message, ['user' => $user]),
- 15,
- 'success',
- true
- );
+ #[\Override]
+ public function assignVariables(): void
+ {
+ parent::assignVariables();
- exit;
+ WCF::getTPL()->assign([
+ 'isExternalAuthentication' => $this->isExternalAuthentication,
+ ]);
}
}
diff --git a/wcfsetup/install/files/lib/form/SettingsForm.class.php b/wcfsetup/install/files/lib/form/SettingsForm.class.php
index 8a62484f454..6f5d6c36e72 100644
--- a/wcfsetup/install/files/lib/form/SettingsForm.class.php
+++ b/wcfsetup/install/files/lib/form/SettingsForm.class.php
@@ -5,7 +5,6 @@
use wcf\command\user\UpdateUser;
use wcf\data\DatabaseObjectBuilder;
use wcf\data\language\Language;
-use wcf\data\option\category\OptionCategory;
use wcf\data\style\Style;
use wcf\data\trophy\Trophy;
use wcf\data\trophy\TrophyCache;
@@ -16,16 +15,13 @@
use wcf\data\user\UserBuilder;
use wcf\data\user\UserProfile;
use wcf\data\user\UserProfileAction;
-use wcf\system\cache\builder\UserOptionCacheBuilder;
use wcf\system\exception\IllegalLinkException;
use wcf\system\form\builder\container\FormContainer;
-use wcf\system\form\builder\field\AbstractFormField;
-use wcf\system\form\builder\field\ICensorshipFormField;
-use wcf\system\form\builder\field\IFormField;
use wcf\system\form\builder\field\MultipleSelectionFormField;
use wcf\system\form\builder\field\SelectFormField;
use wcf\system\form\builder\field\validation\FormFieldValidationError;
use wcf\system\form\builder\field\validation\FormFieldValidator;
+use wcf\system\form\option\user\TUserOptionFormFields;
use wcf\system\language\LanguageFactory;
use wcf\system\menu\user\UserMenu;
use wcf\system\request\LinkHandler;
@@ -44,6 +40,8 @@
*/
final class SettingsForm extends AbstractDatabaseObjectBuilderForm
{
+ use TUserOptionFormFields;
+
/**
* @inheritDoc
*/
@@ -104,7 +102,12 @@ public function readParameters(): void
}
$this->formObject = new User(WCF::getUser()->userID);
- $this->options = $this->getAvailableOptions('settings.' . $this->category);
+ $this->options = $this->getUserOptionsByCategory(
+ 'settings.' . $this->category,
+ static fn(UserOption $option) => $option->validateOptions()
+ && $option->validatePermissions()
+ && $option->isEditable()
+ );
if ($this->category === 'general') {
$this->availableContentLanguages = LanguageFactory::getInstance()->getContentLanguages();
@@ -145,14 +148,7 @@ protected function createForm(): void
$this->form->appendChildren($this->getGeneralFormContainers());
}
- $categories = UserOptionCacheBuilder::getInstance()->getData([], 'categories');
- foreach ($this->options as $categoryName => $options) {
- $this->form->appendChild(
- FormContainer::create('optionCategory' . $categories[$categoryName]->categoryID)
- ->label('wcf.user.option.category.' . $categoryName)
- ->appendChildren(\array_map($this->getOptionFormField(...), $options))
- );
- }
+ $this->form->appendChildren($this->getUserOptionFormContainers($this->options));
}
/**
@@ -295,129 +291,6 @@ static function (MultipleSelectionFormField $formField) use ($maxSpecialTrophies
return $containers;
}
- /**
- * Returns the form field for the given user option.
- */
- protected function getOptionFormField(UserOption $option): AbstractFormField
- {
- $formOption = $option->getFormOption();
- $configuration = $option->getConfiguration();
-
- $formField = $formOption->getFormField($option->getIdentifier(), $configuration)
- ->label('wcf.user.option.' . $option->optionName);
-
- $description = 'wcf.user.option.' . $option->optionName . '.description';
- if (WCF::getLanguage()->get($description, true) !== '') {
- $formField->description($description);
- }
-
- if ((bool)($configuration['required'] ?? false)) {
- $formField->required();
- }
-
- if ($formField instanceof ICensorshipFormField) {
- $formField->censorship();
- }
-
- if ($formField instanceof SelectFormField && isset($formField->getOptions()[$option->defaultValue ?? ''])) {
- $formField->defaultValue($option->defaultValue);
- }
-
- if (($option->validationPattern ?? '') !== '') {
- $formField->addValidator(new FormFieldValidator(
- 'validationPattern',
- static function (IFormField $formField) use ($option) {
- $value = (string)$formField->getValue();
- if ($value !== '' && !\preg_match('~' . $option->validationPattern . '~', $value)) {
- $formField->addValidationError(new FormFieldValidationError(
- 'validationFailed',
- 'wcf.user.option.error.validationFailed'
- ));
- }
- }
- ));
- }
-
- $formField->loadValueCallback(static function (User $user, IFormField $formField) use ($option, $formOption) {
- $value = $user->{$option->getIdentifier()};
- if ($value !== null) {
- $formField->value($formOption->unserializeValue((string)$value));
- }
- });
- $formField->saveValueCallback(static function (UserBuilder $builder, IFormField $formField) use ($option, $formOption) {
- $value = $formField->getSaveValue();
-
- $builder->setUserOption(
- $option->optionID,
- $value !== null ? $formOption->serializeValue($value) : $option->defaultValue
- );
- });
-
- return $formField;
- }
-
- /**
- * Returns the options of the child categories of the given category that
- * the active user can edit, grouped by the name of the child category.
- *
- * @return array
- */
- protected function getAvailableOptions(string $parentCategoryName): array
- {
- $categories = UserOptionCacheBuilder::getInstance()->getData([], 'categories');
- $categoryStructure = UserOptionCacheBuilder::getInstance()->getData([], 'categoryStructure');
-
- $options = [];
- foreach ($categoryStructure[$parentCategoryName] ?? [] as $categoryName) {
- if (!$this->isAccessibleCategory($categories[$categoryName])) {
- continue;
- }
-
- $categoryOptions = $this->getCategoryOptions($categoryName);
- if ($categoryOptions !== []) {
- $options[$categoryName] = $categoryOptions;
- }
- }
-
- return $options;
- }
-
- /**
- * Returns the editable options of the given category and its child categories.
- *
- * @return UserOption[]
- */
- protected function getCategoryOptions(string $categoryName): array
- {
- $categories = UserOptionCacheBuilder::getInstance()->getData([], 'categories');
- $categoryStructure = UserOptionCacheBuilder::getInstance()->getData([], 'categoryStructure');
- $cachedOptions = UserOptionCacheBuilder::getInstance()->getData([], 'options');
- $optionToCategories = UserOptionCacheBuilder::getInstance()->getData([], 'optionToCategories');
-
- $options = [];
- foreach ($optionToCategories[$categoryName] ?? [] as $optionName) {
- $option = $cachedOptions[$optionName];
- if ($option->validateOptions() && $option->validatePermissions() && $option->isEditable()) {
- $options[] = $option;
- }
- }
-
- foreach ($categoryStructure[$categoryName] ?? [] as $childCategoryName) {
- if ($this->isAccessibleCategory($categories[$childCategoryName])) {
- $options = \array_merge($options, $this->getCategoryOptions($childCategoryName));
- }
- }
-
- return $options;
- }
-
- private function isAccessibleCategory(OptionCategory $category): bool
- {
- return $category->categoryName !== 'hidden'
- && $category->validateOptions()
- && $category->validatePermissions();
- }
-
#[\Override]
protected function setFormAction(): void
{
diff --git a/wcfsetup/install/files/lib/system/form/option/user/TUserOptionFormFields.class.php b/wcfsetup/install/files/lib/system/form/option/user/TUserOptionFormFields.class.php
new file mode 100644
index 00000000000..1c67c0b26b0
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/form/option/user/TUserOptionFormFields.class.php
@@ -0,0 +1,178 @@
+
+ * @since 6.3
+ */
+trait TUserOptionFormFields
+{
+ /**
+ * Returns the options of the child categories of the given category that
+ * pass the given filter, grouped by the name of the child category. The
+ * options of deeper categories are added to their child category.
+ *
+ * @param \Closure(UserOption): bool $filter
+ * @return array
+ */
+ protected function getUserOptionsByCategory(string $parentCategoryName, \Closure $filter): array
+ {
+ $categories = UserOptionCacheBuilder::getInstance()->getData([], 'categories');
+ $categoryStructure = UserOptionCacheBuilder::getInstance()->getData([], 'categoryStructure');
+
+ $options = [];
+ foreach ($categoryStructure[$parentCategoryName] ?? [] as $categoryName) {
+ if (!$this->isAccessibleUserOptionCategory($categories[$categoryName])) {
+ continue;
+ }
+
+ $categoryOptions = $this->getUserOptionsOfCategory($categoryName, $filter);
+ if ($categoryOptions !== []) {
+ $options[$categoryName] = $categoryOptions;
+ }
+ }
+
+ return $options;
+ }
+
+ /**
+ * Returns one form container per category with the form fields of the options.
+ *
+ * @param array $optionsByCategory
+ * @return FormContainer[]
+ */
+ protected function getUserOptionFormContainers(array $optionsByCategory): array
+ {
+ $categories = UserOptionCacheBuilder::getInstance()->getData([], 'categories');
+
+ $containers = [];
+ foreach ($optionsByCategory as $categoryName => $options) {
+ $containers[] = FormContainer::create('optionCategory' . $categories[$categoryName]->categoryID)
+ ->label('wcf.user.option.category.' . $categoryName)
+ ->appendChildren(\array_map($this->getUserOptionFormField(...), $options));
+ }
+
+ return $containers;
+ }
+
+ /**
+ * Returns the form field for the given user option.
+ */
+ protected function getUserOptionFormField(UserOption $option): AbstractFormField
+ {
+ $formOption = $option->getFormOption();
+ $configuration = $option->getConfiguration();
+
+ $formField = $formOption->getFormField($option->getIdentifier(), $configuration)
+ ->label('wcf.user.option.' . $option->optionName);
+
+ $description = 'wcf.user.option.' . $option->optionName . '.description';
+ if (WCF::getLanguage()->get($description, true) !== '') {
+ $formField->description($description);
+ }
+
+ if ((bool)($configuration['required'] ?? false)) {
+ $formField->required();
+ }
+
+ if ($formField instanceof ICensorshipFormField) {
+ $formField->censorship();
+ }
+
+ if ($formField instanceof SelectFormField && isset($formField->getOptions()[$option->defaultValue ?? ''])) {
+ $formField->defaultValue($option->defaultValue);
+ }
+
+ if (($option->validationPattern ?? '') !== '') {
+ $formField->addValidator(new FormFieldValidator(
+ 'validationPattern',
+ static function (IFormField $formField) use ($option) {
+ $value = (string)$formField->getValue();
+ if ($value !== '' && !\preg_match('~' . $option->validationPattern . '~', $value)) {
+ $formField->addValidationError(new FormFieldValidationError(
+ 'validationFailed',
+ 'wcf.user.option.error.validationFailed'
+ ));
+ }
+ }
+ ));
+ }
+
+ $formField->loadValueCallback(static function (User $user, IFormField $formField) use ($option, $formOption) {
+ $value = $user->{$option->getIdentifier()};
+
+ // Legacy date options use `0000-00-00` to represent an empty value.
+ if ($value !== null && $value !== '0000-00-00') {
+ $formField->value($formOption->unserializeValue((string)$value));
+ }
+ });
+ $formField->saveValueCallback(static function (UserBuilder $builder, IFormField $formField) use ($option, $formOption) {
+ $value = $formField->getSaveValue();
+
+ $builder->setUserOption(
+ $option->optionID,
+ $value !== null ? $formOption->serializeValue($value) : $option->defaultValue
+ );
+ });
+
+ return $formField;
+ }
+
+ /**
+ * Returns the options of the given category and its child categories that
+ * pass the given filter.
+ *
+ * @param \Closure(UserOption): bool $filter
+ * @return UserOption[]
+ */
+ private function getUserOptionsOfCategory(string $categoryName, \Closure $filter): array
+ {
+ $categories = UserOptionCacheBuilder::getInstance()->getData([], 'categories');
+ $categoryStructure = UserOptionCacheBuilder::getInstance()->getData([], 'categoryStructure');
+ $cachedOptions = UserOptionCacheBuilder::getInstance()->getData([], 'options');
+ $optionToCategories = UserOptionCacheBuilder::getInstance()->getData([], 'optionToCategories');
+
+ $options = [];
+ foreach ($optionToCategories[$categoryName] ?? [] as $optionName) {
+ $option = $cachedOptions[$optionName];
+ if ($filter($option)) {
+ $options[] = $option;
+ }
+ }
+
+ foreach ($categoryStructure[$categoryName] ?? [] as $childCategoryName) {
+ if ($this->isAccessibleUserOptionCategory($categories[$childCategoryName])) {
+ $options = \array_merge($options, $this->getUserOptionsOfCategory($childCategoryName, $filter));
+ }
+ }
+
+ return $options;
+ }
+
+ private function isAccessibleUserOptionCategory(OptionCategory $category): bool
+ {
+ return $category->categoryName !== 'hidden'
+ && $category->validateOptions()
+ && $category->validatePermissions();
+ }
+}
diff --git a/wcfsetup/install/files/lib/util/UserRegistrationUtil.class.php b/wcfsetup/install/files/lib/util/UserRegistrationUtil.class.php
index 15c8fbd55bf..bd7ee74da4b 100644
--- a/wcfsetup/install/files/lib/util/UserRegistrationUtil.class.php
+++ b/wcfsetup/install/files/lib/util/UserRegistrationUtil.class.php
@@ -100,6 +100,8 @@ public static function getActivationCode(int $length = 9): int
/**
* Generates a random field name.
+ *
+ * @deprecated 6.3 No longer in use.
*/
public static function getRandomFieldName(string $unused): string
{
From 921d35b8e43165b3f547a78976b9894aecb61513 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Fri, 2 Oct 2026 16:51:21 +0200
Subject: [PATCH 007/115] Drop `SingletonFactory` requirement for user profile
menu content classes
---
.../menu/item/UserProfileMenuItem.class.php | 12 ++---
.../AboutUserProfileMenuContent.class.php | 13 +++--
.../CommentUserProfileMenuContent.class.php | 50 +++++++++----------
.../LikesUserProfileMenuContent.class.php | 13 +++--
...ntActivityUserProfileMenuContent.class.php | 13 +++--
5 files changed, 47 insertions(+), 54 deletions(-)
diff --git a/wcfsetup/install/files/lib/data/user/profile/menu/item/UserProfileMenuItem.class.php b/wcfsetup/install/files/lib/data/user/profile/menu/item/UserProfileMenuItem.class.php
index 49c6833507a..2dfa8dfee02 100644
--- a/wcfsetup/install/files/lib/data/user/profile/menu/item/UserProfileMenuItem.class.php
+++ b/wcfsetup/install/files/lib/data/user/profile/menu/item/UserProfileMenuItem.class.php
@@ -7,7 +7,6 @@
use wcf\data\TDatabaseObjectPermissions;
use wcf\system\exception\ClassNotFoundException;
use wcf\system\exception\ImplementationException;
-use wcf\system\exception\ParentClassException;
use wcf\system\exception\SystemException;
use wcf\system\menu\user\profile\content\IUserProfileMenuContent;
use wcf\system\SingletonFactory;
@@ -67,15 +66,16 @@ public function getContentManager()
throw new ClassNotFoundException($this->className);
}
- if (!\is_subclass_of($this->className, SingletonFactory::class)) {
- throw new ParentClassException($this->className, SingletonFactory::class);
- }
-
if (!\is_subclass_of($this->className, IUserProfileMenuContent::class)) {
throw new ImplementationException($this->className, IUserProfileMenuContent::class);
}
- $this->contentManager = \call_user_func([$this->className, 'getInstance']);
+ // Extending `SingletonFactory` is no longer required, but still supported for backwards compatibility.
+ if (\is_subclass_of($this->className, SingletonFactory::class)) {
+ $this->contentManager = \call_user_func([$this->className, 'getInstance']);
+ } else {
+ $this->contentManager = new $this->className();
+ }
}
return $this->contentManager;
diff --git a/wcfsetup/install/files/lib/system/menu/user/profile/content/AboutUserProfileMenuContent.class.php b/wcfsetup/install/files/lib/system/menu/user/profile/content/AboutUserProfileMenuContent.class.php
index cef51a88f0c..ce07ac0317f 100644
--- a/wcfsetup/install/files/lib/system/menu/user/profile/content/AboutUserProfileMenuContent.class.php
+++ b/wcfsetup/install/files/lib/system/menu/user/profile/content/AboutUserProfileMenuContent.class.php
@@ -5,17 +5,16 @@
use wcf\data\user\User;
use wcf\system\cache\runtime\UserProfileRuntimeCache;
use wcf\system\option\user\UserOptionHandler;
-use wcf\system\SingletonFactory;
use wcf\system\WCF;
/**
* Handles user profile information content.
*
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License
+ * @author Alexander Ebert
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
*/
-class AboutUserProfileMenuContent extends SingletonFactory implements IUserProfileMenuContent
+final class AboutUserProfileMenuContent implements IUserProfileMenuContent
{
/**
* user option handler object
@@ -24,7 +23,7 @@ class AboutUserProfileMenuContent extends SingletonFactory implements IUserProfi
public $optionHandler;
#[\Override]
- public function getContent(int $userID)
+ public function getContent(int $userID): string
{
if ($this->optionHandler === null) {
$this->optionHandler = new UserOptionHandler(false, '', 'profile');
@@ -43,7 +42,7 @@ public function getContent(int $userID)
}
#[\Override]
- public function isVisible(int $userID)
+ public function isVisible(int $userID): bool
{
return true;
}
diff --git a/wcfsetup/install/files/lib/system/menu/user/profile/content/CommentUserProfileMenuContent.class.php b/wcfsetup/install/files/lib/system/menu/user/profile/content/CommentUserProfileMenuContent.class.php
index 87e77e67e74..bf9a71b828f 100644
--- a/wcfsetup/install/files/lib/system/menu/user/profile/content/CommentUserProfileMenuContent.class.php
+++ b/wcfsetup/install/files/lib/system/menu/user/profile/content/CommentUserProfileMenuContent.class.php
@@ -3,56 +3,52 @@
namespace wcf\system\menu\user\profile\content;
use wcf\system\comment\CommentHandler;
-use wcf\system\SingletonFactory;
+use wcf\system\comment\manager\ICommentManager;
use wcf\system\WCF;
/**
* Handles user profile comment content.
*
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License
+ * @author Alexander Ebert
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
*/
-class CommentUserProfileMenuContent extends SingletonFactory implements IUserProfileMenuContent
+final class CommentUserProfileMenuContent implements IUserProfileMenuContent
{
- /**
- * comment manager object
- * @var ?\wcf\system\comment\manager\ICommentManager
- */
- public $commentManager;
-
- /**
- * object type id
- * @var int
- */
- public $objectTypeID = 0;
+ private ICommentManager $commentManager;
#[\Override]
- public function getContent(int $userID)
+ public function getContent(int $userID): string
{
- if ($this->commentManager === null) {
- $this->objectTypeID = CommentHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.user.profileComment');
- $objectType = CommentHandler::getInstance()->getObjectType($this->objectTypeID);
- $this->commentManager = $objectType->getProcessor();
- }
+ $objectTypeID = CommentHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.user.profileComment');
$commentList = CommentHandler::getInstance()->getCommentList(
- $this->commentManager,
- $this->objectTypeID,
+ $this->getCommentManager(),
+ $objectTypeID,
$userID
);
return WCF::getTPL()->render('wcf', 'userProfileCommentList', [
- 'commentCanAdd' => $this->commentManager->canAdd($userID),
+ 'commentCanAdd' => $this->getCommentManager()->canAdd($userID),
'commentList' => $commentList,
- 'commentObjectTypeID' => $this->objectTypeID,
+ 'commentObjectTypeID' => $objectTypeID,
'userID' => $userID,
'lastCommentTime' => $commentList->getMinCommentTime(),
]);
}
+ private function getCommentManager(): ICommentManager
+ {
+ if (!isset($this->commentManager)) {
+ $objectTypeID = CommentHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.user.profileComment');
+ $this->commentManager = CommentHandler::getInstance()->getObjectType($objectTypeID)->getProcessor();
+ }
+
+ return $this->commentManager;
+ }
+
#[\Override]
- public function isVisible(int $userID)
+ public function isVisible(int $userID): bool
{
return true;
}
diff --git a/wcfsetup/install/files/lib/system/menu/user/profile/content/LikesUserProfileMenuContent.class.php b/wcfsetup/install/files/lib/system/menu/user/profile/content/LikesUserProfileMenuContent.class.php
index 0e4a9a6e24a..c41c1d7f869 100644
--- a/wcfsetup/install/files/lib/system/menu/user/profile/content/LikesUserProfileMenuContent.class.php
+++ b/wcfsetup/install/files/lib/system/menu/user/profile/content/LikesUserProfileMenuContent.class.php
@@ -3,20 +3,19 @@
namespace wcf\system\menu\user\profile\content;
use wcf\data\like\ViewableLikeList;
-use wcf\system\SingletonFactory;
use wcf\system\WCF;
/**
* Handles user profile likes content.
*
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License
+ * @author Alexander Ebert
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
*/
-class LikesUserProfileMenuContent extends SingletonFactory implements IUserProfileMenuContent
+final class LikesUserProfileMenuContent implements IUserProfileMenuContent
{
#[\Override]
- public function getContent(int $userID)
+ public function getContent(int $userID): string
{
$likeList = new ViewableLikeList();
$likeList->getConditionBuilder()->add("like_table.objectUserID = ?", [$userID]);
@@ -30,7 +29,7 @@ public function getContent(int $userID)
}
#[\Override]
- public function isVisible(int $userID)
+ public function isVisible(int $userID): bool
{
if (!WCF::getSession()->hasPermission('user.like.canViewLike')) {
return false;
diff --git a/wcfsetup/install/files/lib/system/menu/user/profile/content/RecentActivityUserProfileMenuContent.class.php b/wcfsetup/install/files/lib/system/menu/user/profile/content/RecentActivityUserProfileMenuContent.class.php
index c06e642eecb..854347ffa9f 100644
--- a/wcfsetup/install/files/lib/system/menu/user/profile/content/RecentActivityUserProfileMenuContent.class.php
+++ b/wcfsetup/install/files/lib/system/menu/user/profile/content/RecentActivityUserProfileMenuContent.class.php
@@ -3,21 +3,20 @@
namespace wcf\system\menu\user\profile\content;
use wcf\data\user\activity\event\ViewableUserActivityEventList;
-use wcf\system\SingletonFactory;
use wcf\system\user\activity\event\UserActivityEventHandler;
use wcf\system\WCF;
/**
* Handles user activity events.
*
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License
+ * @author Alexander Ebert
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
*/
-class RecentActivityUserProfileMenuContent extends SingletonFactory implements IUserProfileMenuContent
+final class RecentActivityUserProfileMenuContent implements IUserProfileMenuContent
{
#[\Override]
- public function getContent(int $userID)
+ public function getContent(int $userID): string
{
$eventList = new ViewableUserActivityEventList();
@@ -41,7 +40,7 @@ public function getContent(int $userID)
}
#[\Override]
- public function isVisible(int $userID)
+ public function isVisible(int $userID): bool
{
return true;
}
From 68d08f99e31bb732e4818c8700e02573f7b1cab7 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Sat, 3 Oct 2026 17:10:54 +0200
Subject: [PATCH 008/115] Render the user profile "About" tab with
UserProfileAboutView and form option formatters
---
.../templates/userProfileAbout.tpl | 24 ++-
.../lib/data/user/UserProfileAction.class.php | 12 +-
.../MultipleSelectionFormatter.class.php | 4 +
.../formatter/SelectFormatter.class.php | 4 +
.../TSelectOptionLabelFormatter.class.php | 32 ++++
.../user/TUserOptionFormFields.class.php | 68 +--------
.../AboutUserProfileMenuContent.class.php | 23 +--
.../user/TUserOptionsByCategory.class.php | 84 +++++++++++
.../profile/UserProfileAboutView.class.php | 139 ++++++++++++++++++
9 files changed, 287 insertions(+), 103 deletions(-)
create mode 100644 wcfsetup/install/files/lib/system/form/option/formatter/TSelectOptionLabelFormatter.class.php
create mode 100644 wcfsetup/install/files/lib/system/option/user/TUserOptionsByCategory.class.php
create mode 100644 wcfsetup/install/files/lib/system/view/user/profile/UserProfileAboutView.class.php
diff --git a/com.woltlab.wcf/templates/userProfileAbout.tpl b/com.woltlab.wcf/templates/userProfileAbout.tpl
index bcf9db8681a..5b46d0df7c6 100644
--- a/com.woltlab.wcf/templates/userProfileAbout.tpl
+++ b/com.woltlab.wcf/templates/userProfileAbout.tpl
@@ -2,19 +2,17 @@
{content}
{event name='beforeUserOptions'}
- {foreach from=$options item=category}
- {foreach from=$category[categories] item=optionCategory}
-
- {lang}wcf.user.option.category.{$optionCategory[object]->categoryName}{/lang}
-
- {foreach from=$optionCategory[options] item=userOption}
-
- {$userOption[object]->getTitle()}
- {unsafe:$userOption[object]->optionValue}
-
- {/foreach}
-
- {/foreach}
+ {foreach from=$view->getOptionValuesOutput() key=categoryName item=userOptions}
+
+ {lang}wcf.user.option.category.{$categoryName}{/lang}
+
+ {foreach from=$userOptions item=userOption}
+
+ {$userOption[object]->getTitle()}
+ {unsafe:$userOption[output]}
+
+ {/foreach}
+
{/foreach}
{event name='afterUserOptions'}
diff --git a/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php b/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php
index 0327ba5ccfb..1b60f61c287 100644
--- a/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php
+++ b/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php
@@ -16,6 +16,7 @@
use wcf\system\upload\UploadFile;
use wcf\system\user\group\assignment\UserGroupAssignmentHandler;
use wcf\system\user\storage\UserStorageHandler;
+use wcf\system\view\user\profile\UserProfileAboutView;
use wcf\system\WCF;
use wcf\util\ArrayUtil;
use wcf\util\MessageUtil;
@@ -307,17 +308,14 @@ public function save()
$action->executeAction();
}
- // reload option handler
- $optionHandler = $this->getOptionHandler($user, false);
-
- // return parsed template
- $options = $optionHandler->getOptionTree();
+ $userProfile = new UserProfile($user);
return [
'success' => true,
'template' => WCF::getTPL()->render('wcf', 'userProfileAbout', [
- 'options' => $options,
- 'userID' => $this->userProfile->userID,
+ 'view' => new UserProfileAboutView($userProfile),
+ 'userID' => $userProfile->userID,
+ 'user' => $userProfile,
]),
];
} else {
diff --git a/wcfsetup/install/files/lib/system/form/option/formatter/MultipleSelectionFormatter.class.php b/wcfsetup/install/files/lib/system/form/option/formatter/MultipleSelectionFormatter.class.php
index e2c153c5ed2..96876c7cb59 100644
--- a/wcfsetup/install/files/lib/system/form/option/formatter/MultipleSelectionFormatter.class.php
+++ b/wcfsetup/install/files/lib/system/form/option/formatter/MultipleSelectionFormatter.class.php
@@ -15,6 +15,8 @@
*/
final class MultipleSelectionFormatter implements IFormOptionFormatter
{
+ use TSelectOptionLabelFormatter;
+
#[\Override]
public function format(string $value, int $languageID, array $configuration): string
{
@@ -37,6 +39,8 @@ public function format(string $value, int $languageID, array $configuration): st
$value = reset($selectOption['value']);
}
+ $value = $this->resolveLabel((string)$value, $languageID);
+
if ($html !== '') {
$html .= ', ';
}
diff --git a/wcfsetup/install/files/lib/system/form/option/formatter/SelectFormatter.class.php b/wcfsetup/install/files/lib/system/form/option/formatter/SelectFormatter.class.php
index 74534c582e2..bede2fe2721 100644
--- a/wcfsetup/install/files/lib/system/form/option/formatter/SelectFormatter.class.php
+++ b/wcfsetup/install/files/lib/system/form/option/formatter/SelectFormatter.class.php
@@ -15,6 +15,8 @@
*/
final class SelectFormatter implements IFormOptionFormatter
{
+ use TSelectOptionLabelFormatter;
+
public function __construct(private readonly bool $encode = true) {}
#[\Override]
@@ -30,6 +32,8 @@ public function format(string $value, int $languageID, array $configuration): st
$value = reset($selectOption['value']);
}
+ $value = $this->resolveLabel((string)$value, $languageID);
+
if ($this->encode) {
return StringUtil::encodeHTML($value);
}
diff --git a/wcfsetup/install/files/lib/system/form/option/formatter/TSelectOptionLabelFormatter.class.php b/wcfsetup/install/files/lib/system/form/option/formatter/TSelectOptionLabelFormatter.class.php
new file mode 100644
index 00000000000..b199875908a
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/form/option/formatter/TSelectOptionLabelFormatter.class.php
@@ -0,0 +1,32 @@
+
+ * @since 6.3
+ */
+trait TSelectOptionLabelFormatter
+{
+ /**
+ * Returns the label of the select option, resolving it if it is the name
+ * of a language item, see `TSelectionFormField::options()`.
+ */
+ private function resolveLabel(string $label, int $languageID): string
+ {
+ if (\preg_match('~^([a-zA-Z0-9-_]+\.){2,}[a-zA-Z0-9-_]+$~', $label) !== 1) {
+ return $label;
+ }
+
+ $language = LanguageFactory::getInstance()->getLanguage($languageID)
+ ?? LanguageFactory::getInstance()->getDefaultLanguage();
+
+ return $language->getDynamicVariable($label);
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/form/option/user/TUserOptionFormFields.class.php b/wcfsetup/install/files/lib/system/form/option/user/TUserOptionFormFields.class.php
index 1c67c0b26b0..a209b7a9851 100644
--- a/wcfsetup/install/files/lib/system/form/option/user/TUserOptionFormFields.class.php
+++ b/wcfsetup/install/files/lib/system/form/option/user/TUserOptionFormFields.class.php
@@ -2,7 +2,6 @@
namespace wcf\system\form\option\user;
-use wcf\data\option\category\OptionCategory;
use wcf\data\user\option\UserOption;
use wcf\data\user\User;
use wcf\data\user\UserBuilder;
@@ -14,6 +13,7 @@
use wcf\system\form\builder\field\SelectFormField;
use wcf\system\form\builder\field\validation\FormFieldValidationError;
use wcf\system\form\builder\field\validation\FormFieldValidator;
+use wcf\system\option\user\TUserOptionsByCategory;
use wcf\system\WCF;
/**
@@ -27,33 +27,7 @@
*/
trait TUserOptionFormFields
{
- /**
- * Returns the options of the child categories of the given category that
- * pass the given filter, grouped by the name of the child category. The
- * options of deeper categories are added to their child category.
- *
- * @param \Closure(UserOption): bool $filter
- * @return array
- */
- protected function getUserOptionsByCategory(string $parentCategoryName, \Closure $filter): array
- {
- $categories = UserOptionCacheBuilder::getInstance()->getData([], 'categories');
- $categoryStructure = UserOptionCacheBuilder::getInstance()->getData([], 'categoryStructure');
-
- $options = [];
- foreach ($categoryStructure[$parentCategoryName] ?? [] as $categoryName) {
- if (!$this->isAccessibleUserOptionCategory($categories[$categoryName])) {
- continue;
- }
-
- $categoryOptions = $this->getUserOptionsOfCategory($categoryName, $filter);
- if ($categoryOptions !== []) {
- $options[$categoryName] = $categoryOptions;
- }
- }
-
- return $options;
- }
+ use TUserOptionsByCategory;
/**
* Returns one form container per category with the form fields of the options.
@@ -137,42 +111,4 @@ static function (IFormField $formField) use ($option) {
return $formField;
}
-
- /**
- * Returns the options of the given category and its child categories that
- * pass the given filter.
- *
- * @param \Closure(UserOption): bool $filter
- * @return UserOption[]
- */
- private function getUserOptionsOfCategory(string $categoryName, \Closure $filter): array
- {
- $categories = UserOptionCacheBuilder::getInstance()->getData([], 'categories');
- $categoryStructure = UserOptionCacheBuilder::getInstance()->getData([], 'categoryStructure');
- $cachedOptions = UserOptionCacheBuilder::getInstance()->getData([], 'options');
- $optionToCategories = UserOptionCacheBuilder::getInstance()->getData([], 'optionToCategories');
-
- $options = [];
- foreach ($optionToCategories[$categoryName] ?? [] as $optionName) {
- $option = $cachedOptions[$optionName];
- if ($filter($option)) {
- $options[] = $option;
- }
- }
-
- foreach ($categoryStructure[$categoryName] ?? [] as $childCategoryName) {
- if ($this->isAccessibleUserOptionCategory($categories[$childCategoryName])) {
- $options = \array_merge($options, $this->getUserOptionsOfCategory($childCategoryName, $filter));
- }
- }
-
- return $options;
- }
-
- private function isAccessibleUserOptionCategory(OptionCategory $category): bool
- {
- return $category->categoryName !== 'hidden'
- && $category->validateOptions()
- && $category->validatePermissions();
- }
}
diff --git a/wcfsetup/install/files/lib/system/menu/user/profile/content/AboutUserProfileMenuContent.class.php b/wcfsetup/install/files/lib/system/menu/user/profile/content/AboutUserProfileMenuContent.class.php
index ce07ac0317f..a83949cc4a9 100644
--- a/wcfsetup/install/files/lib/system/menu/user/profile/content/AboutUserProfileMenuContent.class.php
+++ b/wcfsetup/install/files/lib/system/menu/user/profile/content/AboutUserProfileMenuContent.class.php
@@ -2,9 +2,8 @@
namespace wcf\system\menu\user\profile\content;
-use wcf\data\user\User;
use wcf\system\cache\runtime\UserProfileRuntimeCache;
-use wcf\system\option\user\UserOptionHandler;
+use wcf\system\view\user\profile\UserProfileAboutView;
use wcf\system\WCF;
/**
@@ -16,28 +15,18 @@
*/
final class AboutUserProfileMenuContent implements IUserProfileMenuContent
{
- /**
- * user option handler object
- * @var ?UserOptionHandler
- */
- public $optionHandler;
-
#[\Override]
public function getContent(int $userID): string
{
- if ($this->optionHandler === null) {
- $this->optionHandler = new UserOptionHandler(false, '', 'profile');
- $this->optionHandler->enableEditMode(false);
- $this->optionHandler->showEmptyOptions(false);
+ $user = UserProfileRuntimeCache::getInstance()->getObject($userID);
+ if ($user === null) {
+ return '';
}
- $user = new User($userID);
- $this->optionHandler->setUser($user);
-
return WCF::getTPL()->render('wcf', 'userProfileAbout', [
- 'options' => $this->optionHandler->getOptionTree(),
+ 'view' => new UserProfileAboutView($user),
'userID' => $user->userID,
- 'user' => UserProfileRuntimeCache::getInstance()->getObject($user->userID),
+ 'user' => $user,
]);
}
diff --git a/wcfsetup/install/files/lib/system/option/user/TUserOptionsByCategory.class.php b/wcfsetup/install/files/lib/system/option/user/TUserOptionsByCategory.class.php
new file mode 100644
index 00000000000..2b23b0cf4f4
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/option/user/TUserOptionsByCategory.class.php
@@ -0,0 +1,84 @@
+
+ * @since 6.3
+ */
+trait TUserOptionsByCategory
+{
+ /**
+ * Returns the options of the child categories of the given category that
+ * pass the given filter, grouped by the name of the child category. The
+ * options of deeper categories are added to their child category.
+ *
+ * @param \Closure(UserOption): bool $filter
+ * @return array
+ */
+ protected function getUserOptionsByCategory(string $parentCategoryName, \Closure $filter): array
+ {
+ $categories = UserOptionCacheBuilder::getInstance()->getData([], 'categories');
+ $categoryStructure = UserOptionCacheBuilder::getInstance()->getData([], 'categoryStructure');
+
+ $options = [];
+ foreach ($categoryStructure[$parentCategoryName] ?? [] as $categoryName) {
+ if (!$this->isAccessibleUserOptionCategory($categories[$categoryName])) {
+ continue;
+ }
+
+ $categoryOptions = $this->getUserOptionsOfCategory($categoryName, $filter);
+ if ($categoryOptions !== []) {
+ $options[$categoryName] = $categoryOptions;
+ }
+ }
+
+ return $options;
+ }
+
+ /**
+ * Returns the options of the given category and its child categories that
+ * pass the given filter.
+ *
+ * @param \Closure(UserOption): bool $filter
+ * @return UserOption[]
+ */
+ private function getUserOptionsOfCategory(string $categoryName, \Closure $filter): array
+ {
+ $categories = UserOptionCacheBuilder::getInstance()->getData([], 'categories');
+ $categoryStructure = UserOptionCacheBuilder::getInstance()->getData([], 'categoryStructure');
+ $cachedOptions = UserOptionCacheBuilder::getInstance()->getData([], 'options');
+ $optionToCategories = UserOptionCacheBuilder::getInstance()->getData([], 'optionToCategories');
+
+ $options = [];
+ foreach ($optionToCategories[$categoryName] ?? [] as $optionName) {
+ $option = $cachedOptions[$optionName];
+ if ($filter($option)) {
+ $options[] = $option;
+ }
+ }
+
+ foreach ($categoryStructure[$categoryName] ?? [] as $childCategoryName) {
+ if ($this->isAccessibleUserOptionCategory($categories[$childCategoryName])) {
+ $options = \array_merge($options, $this->getUserOptionsOfCategory($childCategoryName, $filter));
+ }
+ }
+
+ return $options;
+ }
+
+ private function isAccessibleUserOptionCategory(OptionCategory $category): bool
+ {
+ return $category->categoryName !== 'hidden'
+ && $category->validateOptions()
+ && $category->validatePermissions();
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/view/user/profile/UserProfileAboutView.class.php b/wcfsetup/install/files/lib/system/view/user/profile/UserProfileAboutView.class.php
new file mode 100644
index 00000000000..da58a157db3
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/view/user/profile/UserProfileAboutView.class.php
@@ -0,0 +1,139 @@
+
+ * @since 6.3
+ */
+final class UserProfileAboutView
+{
+ use TUserOptionsByCategory;
+
+ /**
+ * @var array
+ */
+ private array $categories;
+
+ public function __construct(
+ public readonly UserProfile $user
+ ) {}
+
+ /**
+ * Returns the user options that are visible to the active user, grouped
+ * by the name of their category.
+ *
+ * @return array
+ */
+ private function getCategories(): array
+ {
+ if (!isset($this->categories)) {
+ $this->categories = $this->getUserOptionsByCategory('profile', function (UserOption $option) {
+ if (!$option->validateOptions() || !$option->validatePermissions()) {
+ return false;
+ }
+
+ // The cached option objects are shared, the visibility depends on the user.
+ $option = clone $option;
+ $option->setUser($this->user->getDecoratedObject());
+
+ return $option->isVisible();
+ });
+ }
+
+ return $this->categories;
+ }
+
+ /**
+ * Returns the formatted values of the options grouped by the name of their
+ * category, options without a value and categories without any formatted
+ * value are skipped.
+ *
+ * @return array>
+ */
+ public function getOptionValuesOutput(): array
+ {
+ $output = [];
+ foreach ($this->getCategories() as $categoryName => $options) {
+ $categoryOutput = $this->getCategoryOptionValuesOutput($options);
+ if ($categoryOutput !== []) {
+ $output[$categoryName] = $categoryOutput;
+ }
+ }
+
+ return $output;
+ }
+
+ /**
+ * @param UserOption[] $options
+ * @return list
+ */
+ private function getCategoryOptionValuesOutput(array $options): array
+ {
+ $output = [];
+ foreach ($options as $option) {
+ $value = $this->user->{$option->getIdentifier()};
+ if ($value === null || !(bool)$value || $value === '0000-00-00') {
+ continue;
+ }
+
+ $formattedValue = match ($option->optionType) {
+ 'birthday' => $this->user->getBirthday(),
+ 'aboutMe', 'message' => $this->formatMessage($option, (string)$value),
+ default => $option->getFormOption()->getFormatter()->format(
+ (string)$value,
+ WCF::getLanguage()->languageID,
+ $option->getConfiguration()
+ ),
+ };
+
+ if ($formattedValue === '') {
+ continue;
+ }
+
+ $output[] = [
+ 'object' => $option,
+ 'output' => $formattedValue,
+ ];
+ }
+
+ return $output;
+ }
+
+ private function formatMessage(UserOption $option, string $value): string
+ {
+ $objectType = $option->messageObjectType ?: 'com.woltlab.wcf.user.aboutMe';
+
+ // Load embedded objects by parsing the original message again because for the user options, there
+ // is no central way to save the embedded objects in the database.
+ $htmlInputProcessor = new HtmlInputProcessor();
+ $htmlInputProcessor->setContext($objectType, $this->user->userID);
+ $htmlInputProcessor->processIntermediate($value);
+ $htmlInputProcessor->processEmbeddedContent($value, $objectType, $this->user->userID);
+ MessageEmbeddedObjectManager::getInstance()->registerTemporaryMessage($htmlInputProcessor);
+
+ return (new WysiwygFormatter($objectType, $this->user->userID))->format(
+ $value,
+ WCF::getLanguage()->languageID,
+ []
+ );
+ }
+}
From a4c0164ee46e8f16955680fd3a3bd91317bf4954 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Sat, 3 Oct 2026 17:57:15 +0200
Subject: [PATCH 009/115] Replace legacy inline profile editor with form
builder dialog
Closes #6094
---
com.woltlab.wcf/templateDelete.xml | 2 +
com.woltlab.wcf/templates/user.tpl | 15 +-
.../templates/userProfileAboutEditable.tpl | 29 --
.../templates/userProfileHeader.tpl | 2 +-
.../templates/userProfileOptionFieldList.tpl | 23 --
.../Core/Component/User/Profile/Editor.ts | 39 +++
.../Core/Controller/User/Profile.ts | 17 +
wcfsetup/install/files/js/WCF.User.js | 302 ------------------
.../Core/Component/User/Profile/Editor.js | 32 ++
.../Core/Controller/User/Profile.js | 15 +
.../action/UserProfileEditAction.class.php | 170 ++++++++++
.../lib/command/user/UpdateUser.class.php | 15 +-
.../lib/data/user/UserProfileAction.class.php | 174 ----------
.../lib/data/user/option/UserOption.class.php | 2 +
.../user/TUserOptionFormFields.class.php | 4 +
15 files changed, 301 insertions(+), 540 deletions(-)
delete mode 100644 com.woltlab.wcf/templates/userProfileAboutEditable.tpl
delete mode 100644 com.woltlab.wcf/templates/userProfileOptionFieldList.tpl
create mode 100644 ts/WoltLabSuite/Core/Component/User/Profile/Editor.ts
create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Component/User/Profile/Editor.js
create mode 100644 wcfsetup/install/files/lib/action/UserProfileEditAction.class.php
diff --git a/com.woltlab.wcf/templateDelete.xml b/com.woltlab.wcf/templateDelete.xml
index 7198bdb3fa6..8cb59f7173c 100644
--- a/com.woltlab.wcf/templateDelete.xml
+++ b/com.woltlab.wcf/templateDelete.xml
@@ -118,5 +118,7 @@
email_notification_userRegistration
imageViewer
avatarEdit
+ userProfileAboutEditable
+ userProfileOptionFieldList
diff --git a/com.woltlab.wcf/templates/user.tpl b/com.woltlab.wcf/templates/user.tpl
index 7df99f62937..52a4f0add71 100644
--- a/com.woltlab.wcf/templates/user.tpl
+++ b/com.woltlab.wcf/templates/user.tpl
@@ -46,18 +46,21 @@
{jsphrase name='wcf.user.activityPoint'}
{/if}
- {if $user->canEdit() || ($__wcf->getUser()->userID == $user->userID && $user->canEditOwnProfile())}
- {jsphrase name='wcf.user.editProfile'}
-
- new WCF.User.Profile.Editor({$user->userID}, {if $editOnInit}true{else}false{/if});
- {/if}
-
{event name='javascriptInit'}
});
require(['WoltLabSuite/Core/Controller/User/Profile'], ({ setup }) => {
setup({$user->userID});
});
+
+ {if $user->canEdit() || ($__wcf->getUser()->userID == $user->userID && $user->canEditOwnProfile())}
+ require(['WoltLabSuite/Core/Component/User/Profile/Editor'], ({ setup }) => {
+ const button = document.querySelector('.jsButtonEditProfile');
+ if (button !== null) {
+ setup(button, {$user->userID}, {if $editOnInit}true{else}false{/if});
+ }
+ });
+ {/if}
diff --git a/com.woltlab.wcf/templates/userProfileAboutEditable.tpl b/com.woltlab.wcf/templates/userProfileAboutEditable.tpl
deleted file mode 100644
index 323b624d376..00000000000
--- a/com.woltlab.wcf/templates/userProfileAboutEditable.tpl
+++ /dev/null
@@ -1,29 +0,0 @@
-{foreach from=$optionTree item=categoryLevel1}
- {foreach from=$categoryLevel1[categories] item=categoryLevel2}
-
- {/foreach}
-{/foreach}
-
-
- {lang}wcf.global.button.save{/lang}
- {lang}wcf.global.button.cancel{/lang}
-
diff --git a/com.woltlab.wcf/templates/userProfileHeader.tpl b/com.woltlab.wcf/templates/userProfileHeader.tpl
index c89e47c2d6b..9bdccde5794 100644
--- a/com.woltlab.wcf/templates/userProfileHeader.tpl
+++ b/com.woltlab.wcf/templates/userProfileHeader.tpl
@@ -46,7 +46,7 @@
{/if}
{if $view->canEditUser()}
-
+
{icon name='pencil'}
{lang}wcf.user.editProfile{/lang}
diff --git a/com.woltlab.wcf/templates/userProfileOptionFieldList.tpl b/com.woltlab.wcf/templates/userProfileOptionFieldList.tpl
deleted file mode 100644
index 6d654a1b5a2..00000000000
--- a/com.woltlab.wcf/templates/userProfileOptionFieldList.tpl
+++ /dev/null
@@ -1,23 +0,0 @@
-{foreach from=$options item=optionData}
- {assign var=option value=$optionData[object]}
- {if $errorType|is_array && $errorType[$option->optionName]|isset}
- {assign var=error value=$errorType[$option->optionName]}
- {else}
- {assign var=error value=''}
- {/if}
-
- {$option->getTitle()}
- {unsafe:$optionData[html]}
- {if $error}
-
- {if $error == 'empty'}
- {lang}wcf.global.form.error.empty{/lang}
- {else}
- {lang}{$langPrefix}error.{$error}{/lang}
- {/if}
-
- {/if}
- {$option->getDescription()}
-
-
-{/foreach}
diff --git a/ts/WoltLabSuite/Core/Component/User/Profile/Editor.ts b/ts/WoltLabSuite/Core/Component/User/Profile/Editor.ts
new file mode 100644
index 00000000000..6fc3609f38d
--- /dev/null
+++ b/ts/WoltLabSuite/Core/Component/User/Profile/Editor.ts
@@ -0,0 +1,39 @@
+/**
+ * Edits the profile of a user in a dialog and reloads the "About" tab afterwards.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.3
+ */
+
+import { dialogFactory } from "WoltLabSuite/Core/Component/Dialog";
+import { showDefaultSuccessSnackbar } from "WoltLabSuite/Core/Component/Snackbar";
+import { reloadTabMenuContent } from "WoltLabSuite/Core/Controller/User/Profile";
+import { promiseMutex } from "WoltLabSuite/Core/Helper/PromiseMutex";
+import { getTabMenu } from "WoltLabSuite/Core/Ui/TabMenu";
+
+async function editProfile(userId: number, endpoint: string): Promise {
+ const { ok } = await dialogFactory().usingFormBuilder().fromEndpoint(endpoint);
+ if (!ok) {
+ return;
+ }
+
+ // Reload the tab before selecting it, otherwise the select event would load
+ // the content of a tab that has not been loaded yet a second time.
+ await reloadTabMenuContent(userId, "about");
+ getTabMenu("profileContent")?.select("about");
+
+ showDefaultSuccessSnackbar();
+}
+
+export function setup(button: HTMLElement, userId: number, editOnInit: boolean): void {
+ const endpoint = button.dataset.endpoint!;
+
+ const openDialog = promiseMutex(() => editProfile(userId, endpoint));
+ button.addEventListener("click", () => openDialog());
+
+ if (editOnInit) {
+ openDialog();
+ }
+}
diff --git a/ts/WoltLabSuite/Core/Controller/User/Profile.ts b/ts/WoltLabSuite/Core/Controller/User/Profile.ts
index 2c0fe49ab08..d749835363e 100644
--- a/ts/WoltLabSuite/Core/Controller/User/Profile.ts
+++ b/ts/WoltLabSuite/Core/Controller/User/Profile.ts
@@ -86,6 +86,23 @@ async function loadTabMenuContent(userId: number, tabName: string): Promise {
+ const container = document.querySelector('.tabMenuContent[data-name="' + tabName + '"]');
+ if (container === null) {
+ return;
+ }
+
+ tabContentLoaded.delete(tabName);
+ container.innerHTML = "";
+
+ await loadTabMenuContent(userId, tabName);
+}
+
export function setup(userId: number): void {
setupFollowingList(userId);
setupFollowerList(userId);
diff --git a/wcfsetup/install/files/js/WCF.User.js b/wcfsetup/install/files/js/WCF.User.js
index a3f910256d5..b7193d85d19 100644
--- a/wcfsetup/install/files/js/WCF.User.js
+++ b/wcfsetup/install/files/js/WCF.User.js
@@ -250,308 +250,6 @@ WCF.User.Profile.TabMenu = Class.extend({
}
});
-if (COMPILER_TARGET_DEFAULT) {
- /**
- * User profile inline editor.
- *
- * @param integer userID
- * @param boolean editOnInit
- */
- WCF.User.Profile.Editor = Class.extend({
- /**
- * current action
- * @var string
- */
- _actionName: '',
-
- _active: false,
-
- /**
- * list of interface buttons
- * @var object
- */
- _buttons: {},
-
- /**
- * cached tab content
- * @var string
- */
- _cachedTemplate: '',
-
- /**
- * action proxy
- * @var WCF.Action.Proxy
- */
- _proxy: null,
-
- /**
- * tab object
- * @var jQuery
- */
- _tab: null,
-
- /**
- * target user id
- * @var integer
- */
- _userID: 0,
-
- /**
- * Initializes the WCF.User.Profile.Editor object.
- *
- * @param integer userID
- * @param boolean editOnInit
- */
- init: function (userID, editOnInit) {
- this._actionName = '';
- this._active = false;
- this._cachedTemplate = '';
- this._tab = $('#about');
- this._userID = userID;
- this._proxy = new WCF.Action.Proxy({
- success: $.proxy(this._success, this)
- });
-
- this._initButtons();
-
- // begin editing on page load
- if (editOnInit) {
- this._beginEdit();
- }
- },
-
- /**
- * Initializes interface buttons.
- */
- _initButtons: function () {
- // create buttons
- this._buttons = {
- beginEdit: $('.jsButtonEditProfile:eq(0)').click(this._beginEdit.bind(this))
- };
- },
-
- /**
- * Begins editing.
- *
- * @param {Event?} event event object
- */
- _beginEdit: function (event) {
- if (event) event.preventDefault();
-
- $("#profileContent").wcfTabs("select", "about");
-
- if (this._active) {
- require(["WoltLabSuite/Core/Ui/Scroll"], (Scroll) => {
- Scroll.element(document.getElementById("profileContent"));
- });
- return
- };
- this._active = true;
-
- this._actionName = 'beginEdit';
- this._buttons.beginEdit.addClass('active');
-
- // load form
- this._proxy.setOption('data', {
- actionName: 'beginEdit',
- className: 'wcf\\data\\user\\UserProfileAction',
- objectIDs: [this._userID]
- });
- this._proxy.sendRequest();
- },
-
- /**
- * Saves input values.
- */
- _save: function () {
- require(["WoltLabSuite/Core/Component/Ckeditor"], ({ getCkeditor }) => {
- const textareas = Array.from(this._tab[0].querySelectorAll("textarea"));
- const scrollToTextarea = textareas.find((textarea) => {
- const editor = getCkeditor(textarea);
- if (editor === undefined) {
- return false;
- }
-
- const data = {
- api: {
- throwError: elInnerError
- },
- valid: true
- };
- WCF.System.Event.fireEvent('com.woltlab.wcf.ckeditor5', `validate_${textarea.id}`, data);
-
- return data.valid === false;
- });
-
- if (scrollToTextarea) {
- scrollToTextarea.parentElement.scrollIntoView({ behavior: 'smooth' });
- return;
- }
-
- this._actionName = 'save';
-
- // collect values
- var $regExp = /values\[([a-zA-Z0-9._-]+)\]/;
- var $values = {};
- this._tab.find('input, textarea, select').each(function (index, element) {
- var $element = $(element);
- var $value = null;
-
- switch ($element.getTagName()) {
- case 'input':
- var $type = $element.attr('type');
-
- if (($type === 'radio' || $type === 'checkbox') && !$element.prop('checked')) {
- return;
- }
- break;
-
- case 'textarea':
- let editor = getCkeditor(element);
- if (editor !== undefined) {
- $value = editor.getHtml();
- }
- break;
- }
-
- var $name = $element.attr('name');
- if ($regExp.test($name)) {
- var $fieldName = RegExp.$1;
- if ($value === null) $value = $element.val();
-
- // check for checkboxes
- if ($element.attr('type') === 'checkbox' && /\[\]$/.test($name)) {
- if (!Array.isArray($values[$fieldName])) {
- $values[$fieldName] = [];
- }
-
- $values[$fieldName].push($value);
- }
- else {
- $values[$fieldName] = $value;
- }
- }
- });
-
- this._proxy.setOption('data', {
- actionName: 'save',
- className: 'wcf\\data\\user\\UserProfileAction',
- objectIDs: [this._userID],
- parameters: {
- values: $values
- }
- });
- this._proxy.sendRequest();
- });
- },
-
- /**
- * Restores back to default view.
- */
- _restore: function () {
- this._actionName = 'restore';
- this._active = false;
- this._buttons.beginEdit.removeClass('active');
-
- this._destroyEditor();
-
- this._tab.html(this._cachedTemplate).children().css({height: 'auto'});
- },
-
- /**
- * Handles successful AJAX requests.
- *
- * @param object data
- * @param string textStatus
- * @param jQuery jqXHR
- */
- _success: function (data, textStatus, jqXHR) {
- switch (this._actionName) {
- case 'beginEdit':
- this._prepareEdit(data);
- break;
-
- case 'save':
- // save was successful, show parsed template
- if (data.returnValues.success) {
- this._cachedTemplate = data.returnValues.template;
- this._restore();
- }
- else {
- this._prepareEdit(data, true);
- }
- break;
- }
- },
-
- /**
- * Prepares editing mode.
- *
- * @param object data
- * @param boolean disableCache
- */
- _prepareEdit: function (data, disableCache) {
- require(["WoltLabSuite/Core/Dom/Util", "WoltLabSuite/Core/Ui/Scroll"], ({ setInnerHtml }, { element }) => {
- this._destroyEditor();
-
- if (disableCache !== true) {
- this._cachedTemplate = this._tab[0].innerHTML;
- }
-
- setInnerHtml(this._tab[0], data.returnValues.template);
-
- // block autocomplete
- this._tab.find('input[type=text]').attr('autocomplete', 'off');
-
- // bind event listener
- this._tab.find('.formSubmit > button[data-type=save]').click($.proxy(this._save, this));
- this._tab.find('.formSubmit > button[data-type=restore]').click($.proxy(this._restore, this));
- this._tab.find('input').keyup(function (event) {
- if (event.which === $.ui.keyCode.ENTER) {
- self._save();
-
- event.preventDefault();
- return false;
- }
- });
-
- element(document.getElementById("profileContent"));
- });
- },
-
- /**
- * Destroys all editor instances within current tab.
- */
- _destroyEditor: function () {
- require(["WoltLabSuite/Core/Component/Ckeditor"], ({ getCkeditor }) => {
- this._tab[0].querySelectorAll("textarea").forEach((textarea) => {
- const editor = getCkeditor(textarea);
- editor?.destroy();
- });
- });
- }
- });
-}
-else {
- WCF.User.Profile.Editor = Class.extend({
- _actionName: "",
- _active: false,
- _buttons: {},
- _cachedTemplate: "",
- _proxy: {},
- _tab: {},
- _userID: 0,
- init: function() {},
- _initButtons: function() {},
- _beginEdit: function() {},
- _save: function() {},
- _restore: function() {},
- _success: function() {},
- _prepareEdit: function() {},
- _destroyEditor: function() {}
- });
-}
-
/**
* Namespace for registration functions.
*/
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/User/Profile/Editor.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/User/Profile/Editor.js
new file mode 100644
index 00000000000..b020585d828
--- /dev/null
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/User/Profile/Editor.js
@@ -0,0 +1,32 @@
+/**
+ * Edits the profile of a user in a dialog and reloads the "About" tab afterwards.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.3
+ */
+define(["require", "exports", "WoltLabSuite/Core/Component/Dialog", "WoltLabSuite/Core/Component/Snackbar", "WoltLabSuite/Core/Controller/User/Profile", "WoltLabSuite/Core/Helper/PromiseMutex", "WoltLabSuite/Core/Ui/TabMenu"], function (require, exports, Dialog_1, Snackbar_1, Profile_1, PromiseMutex_1, TabMenu_1) {
+ "use strict";
+ Object.defineProperty(exports, "__esModule", { value: true });
+ exports.setup = setup;
+ async function editProfile(userId, endpoint) {
+ const { ok } = await (0, Dialog_1.dialogFactory)().usingFormBuilder().fromEndpoint(endpoint);
+ if (!ok) {
+ return;
+ }
+ // Reload the tab before selecting it, otherwise the select event would load
+ // the content of a tab that has not been loaded yet a second time.
+ await (0, Profile_1.reloadTabMenuContent)(userId, "about");
+ (0, TabMenu_1.getTabMenu)("profileContent")?.select("about");
+ (0, Snackbar_1.showDefaultSuccessSnackbar)();
+ }
+ function setup(button, userId, editOnInit) {
+ const endpoint = button.dataset.endpoint;
+ const openDialog = (0, PromiseMutex_1.promiseMutex)(() => editProfile(userId, endpoint));
+ button.addEventListener("click", () => openDialog());
+ if (editOnInit) {
+ openDialog();
+ }
+ }
+});
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Controller/User/Profile.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Controller/User/Profile.js
index a3a7539d825..33c9690263d 100644
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Controller/User/Profile.js
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Controller/User/Profile.js
@@ -8,6 +8,7 @@
define(["require", "exports", "tslib", "WoltLabSuite/Core/Ajax", "WoltLabSuite/Core/Event/Handler", "WoltLabSuite/Core/Dom/Util", "WoltLabSuite/Core/Dom/Change/Listener", "WoltLabSuite/Core/Ui/TabMenu", "WoltLabSuite/Core/Helper/PromiseMutex", "WoltLabSuite/Core/Component/Dialog"], function (require, exports, tslib_1, Ajax_1, EventHandler, Util_1, Listener_1, TabMenu_1, PromiseMutex_1, Dialog_1) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
+ exports.reloadTabMenuContent = reloadTabMenuContent;
exports.setup = setup;
EventHandler = tslib_1.__importStar(EventHandler);
function setupUserList(userId, buttonId, className) {
@@ -61,6 +62,20 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Ajax", "WoltLabSuite/C
(0, Util_1.insertHtml)(response.template, document.querySelector('.tabMenuContent[data-name="' + tabName + '"]'), "append");
(0, Listener_1.trigger)();
}
+ /**
+ * Replaces the content of the given tab with its current content.
+ *
+ * @since 6.3
+ */
+ async function reloadTabMenuContent(userId, tabName) {
+ const container = document.querySelector('.tabMenuContent[data-name="' + tabName + '"]');
+ if (container === null) {
+ return;
+ }
+ tabContentLoaded.delete(tabName);
+ container.innerHTML = "";
+ await loadTabMenuContent(userId, tabName);
+ }
function setup(userId) {
setupFollowingList(userId);
setupFollowerList(userId);
diff --git a/wcfsetup/install/files/lib/action/UserProfileEditAction.class.php b/wcfsetup/install/files/lib/action/UserProfileEditAction.class.php
new file mode 100644
index 00000000000..2fbc15dfd00
--- /dev/null
+++ b/wcfsetup/install/files/lib/action/UserProfileEditAction.class.php
@@ -0,0 +1,170 @@
+
+ * @since 6.3
+ */
+final class UserProfileEditAction implements RequestHandlerInterface
+{
+ use TUserOptionFormFields {
+ getUserOptionFormField as getDefaultUserOptionFormField;
+ }
+
+ private UserProfile $user;
+
+ #[\Override]
+ public function handle(ServerRequestInterface $request): ResponseInterface
+ {
+ $parameters = Helper::mapQueryParameters(
+ $request->getQueryParams(),
+ <<<'EOT'
+ array {
+ id: positive-int
+ }
+ EOT
+ );
+
+ $user = UserProfileRuntimeCache::getInstance()->getObject($parameters['id']);
+ $this->assertProfileCanBeEdited($user);
+ $this->user = $user;
+
+ $form = $this->getForm();
+
+ if ($request->getMethod() === 'GET') {
+ return $form->toResponse();
+ } elseif ($request->getMethod() === 'POST') {
+ $response = $form->validateRequest($request);
+ if ($response !== null) {
+ return $response;
+ }
+
+ $builder = UserBuilder::forUpdate($this->user->getDecoratedObject());
+ $form->applyValuesToBuilder($builder);
+ (new UpdateUser($builder))();
+
+ return new JsonResponse([
+ 'result' => [],
+ ]);
+ } else {
+ throw new \LogicException('Unreachable');
+ }
+ }
+
+ /**
+ * @phpstan-assert UserProfile $user
+ */
+ private function assertProfileCanBeEdited(?UserProfile $user): void
+ {
+ if ($user === null || $user->isGuest()) {
+ throw new IllegalLinkException();
+ }
+
+ if ($user->userID !== WCF::getUser()->userID) {
+ if (!$user->canEdit()) {
+ throw new PermissionDeniedException();
+ }
+ } elseif (!$user->canEditOwnProfile()) {
+ throw new PermissionDeniedException();
+ }
+ }
+
+ private function getForm(): Psr15BuilderDialogForm
+ {
+ $form = new Psr15BuilderDialogForm(
+ static::class,
+ WCF::getLanguage()->get('wcf.user.editProfile')
+ );
+
+ if (\MODULE_USER_RANK !== 0 && WCF::getSession()->hasPermission('user.profile.canEditUserTitle')) {
+ $form->appendChild(
+ FormContainer::create('general')
+ ->appendChild($this->getUserTitleFormField())
+ );
+ }
+
+ $form->appendChildren($this->getUserOptionFormContainers(
+ $this->getUserOptionsByCategory('profile', function (UserOption $option) {
+ if (!$option->validateOptions() || !$option->validatePermissions()) {
+ return false;
+ }
+
+ // The cached option objects are shared, the editability depends on the user.
+ $option = clone $option;
+ $option->setUser($this->user->getDecoratedObject());
+
+ return $option->isEditable();
+ })
+ ));
+
+ $form->updatedObject($this->user->getDecoratedObject());
+ $form->build();
+
+ return $form;
+ }
+
+ private function getUserTitleFormField(): TextFormField
+ {
+ return TextFormField::create('userTitle')
+ ->label('wcf.user.userTitle')
+ ->description('wcf.user.userTitle.description')
+ ->maximumLength(\USER_TITLE_MAX_LENGTH)
+ ->addValidator(new FormFieldValidator(
+ 'forbidden',
+ static function (TextFormField $formField) {
+ $userTitle = StringUtil::trim((string)$formField->getValue());
+ if (!StringUtil::executeWordFilter($userTitle, \USER_FORBIDDEN_TITLES)) {
+ $formField->addValidationError(new FormFieldValidationError(
+ 'forbidden',
+ 'wcf.user.userTitle.error.forbidden'
+ ));
+ }
+ }
+ ))
+ ->saveValueCallback(static function (UserBuilder $builder, TextFormField $formField) {
+ $builder->setUserTitle(StringUtil::trim((string)$formField->getValue()));
+ });
+ }
+
+ /**
+ * Returns the form field for the given user option, administrators may
+ * leave required options empty when editing other users.
+ */
+ private function getUserOptionFormField(UserOption $option): AbstractFormField
+ {
+ $formField = $this->getDefaultUserOptionFormField($option);
+
+ if ($this->user->userID !== WCF::getUser()->userID) {
+ $formField->required(false);
+ }
+
+ return $formField;
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/user/UpdateUser.class.php b/wcfsetup/install/files/lib/command/user/UpdateUser.class.php
index bd158067ad0..df284b6275e 100644
--- a/wcfsetup/install/files/lib/command/user/UpdateUser.class.php
+++ b/wcfsetup/install/files/lib/command/user/UpdateUser.class.php
@@ -54,19 +54,24 @@ public function __invoke(): User
UserStorageHandler::getInstance()->reset([$user->userID], 'languageIDs');
}
- if ($this->builder->getGroupIDs() !== null) {
+ $groupsChanged = $this->builder->getGroupIDs() !== null;
+ if ($groupsChanged) {
UserStorageHandler::getInstance()->reset([$user->userID], 'groupIDs');
+ }
+ // The automatic group assignments and the user rank can depend on user options.
+ if ($groupsChanged || $this->builder->getUserOptions() !== null) {
UserGroupAssignmentHandler::getInstance()->checkUsers([$user->userID]);
if (\MODULE_USER_RANK !== 0) {
$action = new UserProfileAction([$user], 'updateUserRank');
$action->executeAction();
}
- if (\MODULE_USERS_ONLINE !== 0) {
- $action = new UserProfileAction([$user], 'updateUserOnlineMarking');
- $action->executeAction();
- }
+ }
+
+ if ($groupsChanged && \MODULE_USERS_ONLINE !== 0) {
+ $action = new UserProfileAction([$user], 'updateUserOnlineMarking');
+ $action->executeAction();
}
EventHandler::getInstance()->fire(new UserUpdated($user, $this->builder));
diff --git a/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php b/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php
index 1b60f61c287..eac85974f68 100644
--- a/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php
+++ b/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php
@@ -12,15 +12,10 @@
use wcf\system\html\input\HtmlInputProcessor;
use wcf\system\html\output\HtmlOutputProcessor;
use wcf\system\message\embedded\object\MessageEmbeddedObjectManager;
-use wcf\system\option\user\UserOptionHandler;
use wcf\system\upload\UploadFile;
-use wcf\system\user\group\assignment\UserGroupAssignmentHandler;
use wcf\system\user\storage\UserStorageHandler;
-use wcf\system\view\user\profile\UserProfileAboutView;
use wcf\system\WCF;
use wcf\util\ArrayUtil;
-use wcf\util\MessageUtil;
-use wcf\util\StringUtil;
/**
* Executes user profile-related actions.
@@ -179,158 +174,6 @@ public function getDetailedActivityPointList()
];
}
- /**
- * Validates parameters to begin profile inline editing.
- *
- * @return void
- * @throws PermissionDeniedException
- * @throws UserInputException
- */
- public function validateBeginEdit()
- {
- if ($this->objectIDs !== [] && \count($this->objectIDs) === 1) {
- $userID = \reset($this->objectIDs);
- $this->userProfile = UserProfileRuntimeCache::getInstance()->getObject($userID);
- }
-
- if ($this->userProfile === null || $this->userProfile->isGuest()) {
- throw new UserInputException('objectIDs');
- }
-
- if ($this->userProfile->userID !== WCF::getUser()->userID) {
- if (!$this->userProfile->canEdit()) {
- throw new PermissionDeniedException();
- }
- } elseif (!$this->userProfile->canEditOwnProfile()) {
- throw new PermissionDeniedException();
- }
- }
-
- /**
- * Begins profile inline editing.
- *
- * @return array{template: string}
- */
- public function beginEdit()
- {
- $optionTree = $this->getOptionHandler($this->userProfile->getDecoratedObject())->getOptionTree();
-
- return [
- 'template' => WCF::getTPL()->render('wcf', 'userProfileAboutEditable', [
- 'errorType' => [],
- 'optionTree' => $optionTree,
- '__userTitle' => $this->userProfile->userTitle,
- ]),
- ];
- }
-
- /**
- * Validates parameters to save changes to user profile.
- *
- * @return void
- * @throws PermissionDeniedException
- */
- public function validateSave()
- {
- $this->validateBeginEdit();
-
- if (!isset($this->parameters['values']) || !\is_array($this->parameters['values'])) {
- $this->parameters['values'] = [];
- }
-
- if (
- isset($this->parameters['values']['__userTitle'])
- && !WCF::getSession()->hasPermission('user.profile.canEditUserTitle')
- ) {
- throw new PermissionDeniedException();
- }
- }
-
- /**
- * Saves changes to user profile.
- *
- * @return array{success: bool, template: string}
- */
- public function save()
- {
- $userTitle = null;
- if (isset($this->parameters['values']['__userTitle'])) {
- $userTitle = StringUtil::trim(MessageUtil::stripCrap($this->parameters['values']['__userTitle']));
- unset($this->parameters['values']['__userTitle']);
- }
-
- $optionHandler = $this->getOptionHandler($this->userProfile->getDecoratedObject());
- $optionHandler->readUserInput($this->parameters);
-
- $errors = $optionHandler->validate();
-
- // validate user title
- if ($userTitle !== null) {
- try {
- if (\mb_strlen($userTitle) > \USER_TITLE_MAX_LENGTH) {
- throw new UserInputException('__userTitle', 'tooLong');
- }
- if (!StringUtil::executeWordFilter($userTitle, \USER_FORBIDDEN_TITLES)) {
- throw new UserInputException('__userTitle', 'forbidden');
- }
- } catch (UserInputException $e) {
- $errors[$e->getField()] = $e->getType();
- }
- }
-
- // validation was successful
- if ($errors === []) {
- $saveOptions = $optionHandler->save();
- $data = [
- 'options' => $saveOptions,
- ];
-
- // save user title
- if ($userTitle !== null) {
- $data['data'] = [
- 'userTitle' => $userTitle,
- ];
- }
-
- $userAction = new UserAction([$this->userProfile->userID], 'update', $data);
- $userAction->executeAction();
-
- // check if the user will be automatically added to new
- // user groups because of the changed user options
- UserGroupAssignmentHandler::getInstance()->checkUsers([$this->userProfile->userID]);
-
- // reload user object to get updated data
- $user = new User($this->userProfile->userID);
-
- // update user rank
- if (\MODULE_USER_RANK !== 0) {
- $action = new self([new UserEditor($user)], 'updateUserRank');
- $action->executeAction();
- }
-
- $userProfile = new UserProfile($user);
-
- return [
- 'success' => true,
- 'template' => WCF::getTPL()->render('wcf', 'userProfileAbout', [
- 'view' => new UserProfileAboutView($userProfile),
- 'userID' => $userProfile->userID,
- 'user' => $userProfile,
- ]),
- ];
- } else {
- // validation failed
- return [
- 'success' => false,
- 'template' => WCF::getTPL()->render('wcf', 'userProfileAboutEditable', [
- 'errorType' => $errors,
- 'optionTree' => $optionHandler->getOptionTree(),
- '__userTitle' => $userTitle !== null ? $userTitle : $this->userProfile->userTitle,
- ]),
- ];
- }
- }
-
/**
* Updates user ranks.
*
@@ -549,21 +392,4 @@ public function updateSpecialTrophies()
UserStorageHandler::getInstance()->reset([$user->userID], 'specialTrophies');
}
}
-
- /**
- * Returns the user option handler object.
- *
- * @return UserOptionHandler
- */
- protected function getOptionHandler(User $user, bool $editMode = true)
- {
- $optionHandler = new UserOptionHandler(false, '', 'profile');
- if (!$editMode) {
- $optionHandler->showEmptyOptions(false);
- $optionHandler->enableEditMode(false);
- }
- $optionHandler->setUser($user);
-
- return $optionHandler;
- }
}
diff --git a/wcfsetup/install/files/lib/data/user/option/UserOption.class.php b/wcfsetup/install/files/lib/data/user/option/UserOption.class.php
index c016fe9c678..bb04b9c9ec6 100644
--- a/wcfsetup/install/files/lib/data/user/option/UserOption.class.php
+++ b/wcfsetup/install/files/lib/data/user/option/UserOption.class.php
@@ -115,11 +115,13 @@ class UserOption extends Option implements ITitledObject
* @var array
*/
private const FORM_OPTION_TYPES = [
+ 'aboutMe' => 'wysiwyg',
'birthday' => 'date',
'boolean' => 'boolean',
'date' => 'date',
'float' => 'float',
'integer' => 'integer',
+ 'message' => 'wysiwyg',
'radioButton' => 'radioButton',
'select' => 'select',
'text' => 'text',
diff --git a/wcfsetup/install/files/lib/system/form/option/user/TUserOptionFormFields.class.php b/wcfsetup/install/files/lib/system/form/option/user/TUserOptionFormFields.class.php
index a209b7a9851..d84bb53cc0e 100644
--- a/wcfsetup/install/files/lib/system/form/option/user/TUserOptionFormFields.class.php
+++ b/wcfsetup/install/files/lib/system/form/option/user/TUserOptionFormFields.class.php
@@ -13,6 +13,7 @@
use wcf\system\form\builder\field\SelectFormField;
use wcf\system\form\builder\field\validation\FormFieldValidationError;
use wcf\system\form\builder\field\validation\FormFieldValidator;
+use wcf\system\form\option\WysiwygFormOption;
use wcf\system\option\user\TUserOptionsByCategory;
use wcf\system\WCF;
@@ -55,6 +56,9 @@ protected function getUserOptionFormContainers(array $optionsByCategory): array
protected function getUserOptionFormField(UserOption $option): AbstractFormField
{
$formOption = $option->getFormOption();
+ if ($formOption instanceof WysiwygFormOption) {
+ $formOption->setContext($option->messageObjectType ?: 'com.woltlab.wcf.user.aboutMe', 0);
+ }
$configuration = $option->getConfiguration();
$formField = $formOption->getFormField($option->getIdentifier(), $configuration)
From f4e583638c03ff47ad115c72c4f44bbb426256f2 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Sat, 3 Oct 2026 18:19:38 +0200
Subject: [PATCH 010/115] Move user profile tab content loading to RPC endpoint
---
.../Api/Users/RenderUserProfileMenuContent.ts | 24 +++++++
.../Core/Controller/User/Profile.ts | 15 +---
.../Api/Users/RenderUserProfileMenuContent.js | 20 ++++++
.../Core/Controller/User/Profile.js | 11 +--
.../item/UserProfileMenuItemAction.class.php | 2 +
.../RenderUserProfileMenuContent.class.php | 71 +++++++++++++++++++
6 files changed, 121 insertions(+), 22 deletions(-)
create mode 100644 ts/WoltLabSuite/Core/Api/Users/RenderUserProfileMenuContent.ts
create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Api/Users/RenderUserProfileMenuContent.js
create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/users/RenderUserProfileMenuContent.class.php
diff --git a/ts/WoltLabSuite/Core/Api/Users/RenderUserProfileMenuContent.ts b/ts/WoltLabSuite/Core/Api/Users/RenderUserProfileMenuContent.ts
new file mode 100644
index 00000000000..5fb52e6d812
--- /dev/null
+++ b/ts/WoltLabSuite/Core/Api/Users/RenderUserProfileMenuContent.ts
@@ -0,0 +1,24 @@
+/**
+ * Renders the content of a user profile menu item.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.3
+ */
+
+import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend";
+import { fromInfallibleApiRequest } from "WoltLabSuite/Core/Api/Result";
+
+type Response = {
+ template: string;
+};
+
+export async function renderUserProfileMenuContent(userId: number, menuItem: string): Promise {
+ const url = new URL(`${window.WSC_RPC_API_URL}core/users/${userId}/profile-menu-content`);
+ url.searchParams.set("menuItem", menuItem);
+
+ return fromInfallibleApiRequest(() => {
+ return prepareRequest(url).get().fetchAsJson();
+ });
+}
diff --git a/ts/WoltLabSuite/Core/Controller/User/Profile.ts b/ts/WoltLabSuite/Core/Controller/User/Profile.ts
index d749835363e..58ce693aca1 100644
--- a/ts/WoltLabSuite/Core/Controller/User/Profile.ts
+++ b/ts/WoltLabSuite/Core/Controller/User/Profile.ts
@@ -6,13 +6,13 @@
* @license GNU Lesser General Public License
*/
-import { dboAction } from "WoltLabSuite/Core/Ajax";
import * as EventHandler from "WoltLabSuite/Core/Event/Handler";
import { insertHtml } from "WoltLabSuite/Core/Dom/Util";
import { trigger as triggerDomChange } from "WoltLabSuite/Core/Dom/Change/Listener";
import { getTabMenu } from "WoltLabSuite/Core/Ui/TabMenu";
import { promiseMutex } from "WoltLabSuite/Core/Helper/PromiseMutex";
import { dialogFactory } from "WoltLabSuite/Core/Component/Dialog";
+import { renderUserProfileMenuContent } from "WoltLabSuite/Core/Api/Users/RenderUserProfileMenuContent";
function setupUserList(userId: number, buttonId: string, className: string): void {
const button = document.getElementById(buttonId) as HTMLElement;
@@ -62,23 +62,12 @@ function setupTabMenu(userId: number): void {
});
}
-type ResponseGetTabContent = {
- template: string;
-};
-
async function loadTabMenuContent(userId: number, tabName: string): Promise {
if (tabContentLoaded.has(tabName)) {
return;
}
- const response = (await dboAction("getContent", "wcf\\data\\user\\profile\\menu\\item\\UserProfileMenuItemAction")
- .payload({
- data: {
- menuItem: tabName,
- userID: userId,
- },
- })
- .dispatch()) as ResponseGetTabContent;
+ const response = await renderUserProfileMenuContent(userId, tabName);
tabContentLoaded.set(tabName, true);
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Users/RenderUserProfileMenuContent.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Users/RenderUserProfileMenuContent.js
new file mode 100644
index 00000000000..053eb4455c4
--- /dev/null
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Users/RenderUserProfileMenuContent.js
@@ -0,0 +1,20 @@
+/**
+ * Renders the content of a user profile menu item.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.3
+ */
+define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Core/Api/Result"], function (require, exports, Backend_1, Result_1) {
+ "use strict";
+ Object.defineProperty(exports, "__esModule", { value: true });
+ exports.renderUserProfileMenuContent = renderUserProfileMenuContent;
+ async function renderUserProfileMenuContent(userId, menuItem) {
+ const url = new URL(`${window.WSC_RPC_API_URL}core/users/${userId}/profile-menu-content`);
+ url.searchParams.set("menuItem", menuItem);
+ return (0, Result_1.fromInfallibleApiRequest)(() => {
+ return (0, Backend_1.prepareRequest)(url).get().fetchAsJson();
+ });
+ }
+});
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Controller/User/Profile.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Controller/User/Profile.js
index 33c9690263d..b33f9eef042 100644
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Controller/User/Profile.js
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Controller/User/Profile.js
@@ -5,7 +5,7 @@
* @copyright 2001-2022 WoltLab GmbH
* @license GNU Lesser General Public License
*/
-define(["require", "exports", "tslib", "WoltLabSuite/Core/Ajax", "WoltLabSuite/Core/Event/Handler", "WoltLabSuite/Core/Dom/Util", "WoltLabSuite/Core/Dom/Change/Listener", "WoltLabSuite/Core/Ui/TabMenu", "WoltLabSuite/Core/Helper/PromiseMutex", "WoltLabSuite/Core/Component/Dialog"], function (require, exports, tslib_1, Ajax_1, EventHandler, Util_1, Listener_1, TabMenu_1, PromiseMutex_1, Dialog_1) {
+define(["require", "exports", "tslib", "WoltLabSuite/Core/Event/Handler", "WoltLabSuite/Core/Dom/Util", "WoltLabSuite/Core/Dom/Change/Listener", "WoltLabSuite/Core/Ui/TabMenu", "WoltLabSuite/Core/Helper/PromiseMutex", "WoltLabSuite/Core/Component/Dialog", "WoltLabSuite/Core/Api/Users/RenderUserProfileMenuContent"], function (require, exports, tslib_1, EventHandler, Util_1, Listener_1, TabMenu_1, PromiseMutex_1, Dialog_1, RenderUserProfileMenuContent_1) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.reloadTabMenuContent = reloadTabMenuContent;
@@ -50,14 +50,7 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Ajax", "WoltLabSuite/C
if (tabContentLoaded.has(tabName)) {
return;
}
- const response = (await (0, Ajax_1.dboAction)("getContent", "wcf\\data\\user\\profile\\menu\\item\\UserProfileMenuItemAction")
- .payload({
- data: {
- menuItem: tabName,
- userID: userId,
- },
- })
- .dispatch());
+ const response = await (0, RenderUserProfileMenuContent_1.renderUserProfileMenuContent)(userId, tabName);
tabContentLoaded.set(tabName, true);
(0, Util_1.insertHtml)(response.template, document.querySelector('.tabMenuContent[data-name="' + tabName + '"]'), "append");
(0, Listener_1.trigger)();
diff --git a/wcfsetup/install/files/lib/data/user/profile/menu/item/UserProfileMenuItemAction.class.php b/wcfsetup/install/files/lib/data/user/profile/menu/item/UserProfileMenuItemAction.class.php
index 84e9df6ca20..1a3af2074e5 100644
--- a/wcfsetup/install/files/lib/data/user/profile/menu/item/UserProfileMenuItemAction.class.php
+++ b/wcfsetup/install/files/lib/data/user/profile/menu/item/UserProfileMenuItemAction.class.php
@@ -44,6 +44,7 @@ class UserProfileMenuItemAction extends AbstractDatabaseObjectAction implements
* Validates menu item.
*
* @return void
+ * @deprecated 6.3 use `wcf\system\endpoint\controller\core\users\RenderUserProfileMenuContent` instead
*/
public function validateGetContent()
{
@@ -80,6 +81,7 @@ public function validateGetContent()
* Returns content for given menu item.
*
* @return array{template: string}
+ * @deprecated 6.3 use `wcf\system\endpoint\controller\core\users\RenderUserProfileMenuContent` instead
*/
public function getContent()
{
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/RenderUserProfileMenuContent.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/RenderUserProfileMenuContent.class.php
new file mode 100644
index 00000000000..38f3625015c
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/RenderUserProfileMenuContent.class.php
@@ -0,0 +1,71 @@
+
+ * @since 6.3
+ */
+#[GetRequest('/core/users/{id:\d+}/profile-menu-content')]
+final class RenderUserProfileMenuContent implements IController
+{
+ #[\Override]
+ public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface
+ {
+ $parameters = Helper::mapApiParameters($request, RenderUserProfileMenuContentParameters::class);
+
+ $user = Helper::fetchObjectFromRequestParameter($variables['id'], User::class);
+
+ $menuItem = UserProfileMenu::getInstance()->getMenuItem($parameters->menuItem);
+ if ($menuItem === null) {
+ throw new IllegalLinkException();
+ }
+
+ $contentManager = $menuItem->getContentManager();
+ if (!$contentManager->isVisible($user->userID)) {
+ throw new PermissionDeniedException();
+ }
+
+ $userProfile = UserProfileRuntimeCache::getInstance()->getObject($user->userID);
+ if (
+ $userProfile->userID !== WCF::getUser()->userID
+ && !WCF::getSession()->hasPermission('user.profile.canViewUserProfile')
+ ) {
+ throw new PermissionDeniedException();
+ }
+
+ if ($userProfile->isProtected()) {
+ throw new PermissionDeniedException();
+ }
+
+ return new JsonResponse([
+ 'template' => $contentManager->getContent($user->userID),
+ ]);
+ }
+}
+
+/** @internal */
+final class RenderUserProfileMenuContentParameters
+{
+ public function __construct(
+ /** @var non-empty-string */
+ public readonly string $menuItem,
+ ) {}
+}
From 8cf4ffff73f54885a7e5055fe56476ffb0e4cb54 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Sat, 3 Oct 2026 19:41:23 +0200
Subject: [PATCH 011/115] Decouple profile editor from tab menu and set it up
via profile controller
---
com.woltlab.wcf/templates/user.tpl | 11 +---------
.../Core/Component/User/Profile/Editor.ts | 15 +++++--------
.../Core/Controller/User/Profile.ts | 22 +++++++++++++++----
.../Core/Component/User/Profile/Editor.js | 15 +++++--------
.../Core/Controller/User/Profile.js | 20 ++++++++++++-----
5 files changed, 45 insertions(+), 38 deletions(-)
diff --git a/com.woltlab.wcf/templates/user.tpl b/com.woltlab.wcf/templates/user.tpl
index 52a4f0add71..2e67f50a38f 100644
--- a/com.woltlab.wcf/templates/user.tpl
+++ b/com.woltlab.wcf/templates/user.tpl
@@ -50,17 +50,8 @@
});
require(['WoltLabSuite/Core/Controller/User/Profile'], ({ setup }) => {
- setup({$user->userID});
+ setup({$user->userID}, {if $editOnInit}true{else}false{/if});
});
-
- {if $user->canEdit() || ($__wcf->getUser()->userID == $user->userID && $user->canEditOwnProfile())}
- require(['WoltLabSuite/Core/Component/User/Profile/Editor'], ({ setup }) => {
- const button = document.querySelector('.jsButtonEditProfile');
- if (button !== null) {
- setup(button, {$user->userID}, {if $editOnInit}true{else}false{/if});
- }
- });
- {/if}
diff --git a/ts/WoltLabSuite/Core/Component/User/Profile/Editor.ts b/ts/WoltLabSuite/Core/Component/User/Profile/Editor.ts
index 6fc3609f38d..37b91c08f49 100644
--- a/ts/WoltLabSuite/Core/Component/User/Profile/Editor.ts
+++ b/ts/WoltLabSuite/Core/Component/User/Profile/Editor.ts
@@ -1,5 +1,5 @@
/**
- * Edits the profile of a user in a dialog and reloads the "About" tab afterwards.
+ * Edits the profile of a user in a dialog.
*
* @author Marcel Werk
* @copyright 2001-2026 WoltLab GmbH
@@ -9,28 +9,23 @@
import { dialogFactory } from "WoltLabSuite/Core/Component/Dialog";
import { showDefaultSuccessSnackbar } from "WoltLabSuite/Core/Component/Snackbar";
-import { reloadTabMenuContent } from "WoltLabSuite/Core/Controller/User/Profile";
import { promiseMutex } from "WoltLabSuite/Core/Helper/PromiseMutex";
-import { getTabMenu } from "WoltLabSuite/Core/Ui/TabMenu";
-async function editProfile(userId: number, endpoint: string): Promise {
+async function editProfile(endpoint: string, onSuccess: () => Promise): Promise {
const { ok } = await dialogFactory().usingFormBuilder().fromEndpoint(endpoint);
if (!ok) {
return;
}
- // Reload the tab before selecting it, otherwise the select event would load
- // the content of a tab that has not been loaded yet a second time.
- await reloadTabMenuContent(userId, "about");
- getTabMenu("profileContent")?.select("about");
+ await onSuccess();
showDefaultSuccessSnackbar();
}
-export function setup(button: HTMLElement, userId: number, editOnInit: boolean): void {
+export function setup(button: HTMLElement, editOnInit: boolean, onSuccess: () => Promise): void {
const endpoint = button.dataset.endpoint!;
- const openDialog = promiseMutex(() => editProfile(userId, endpoint));
+ const openDialog = promiseMutex(() => editProfile(endpoint, onSuccess));
button.addEventListener("click", () => openDialog());
if (editOnInit) {
diff --git a/ts/WoltLabSuite/Core/Controller/User/Profile.ts b/ts/WoltLabSuite/Core/Controller/User/Profile.ts
index 58ce693aca1..51c8bad5744 100644
--- a/ts/WoltLabSuite/Core/Controller/User/Profile.ts
+++ b/ts/WoltLabSuite/Core/Controller/User/Profile.ts
@@ -13,6 +13,7 @@ import { getTabMenu } from "WoltLabSuite/Core/Ui/TabMenu";
import { promiseMutex } from "WoltLabSuite/Core/Helper/PromiseMutex";
import { dialogFactory } from "WoltLabSuite/Core/Component/Dialog";
import { renderUserProfileMenuContent } from "WoltLabSuite/Core/Api/Users/RenderUserProfileMenuContent";
+import { setup as setupProfileEditor } from "WoltLabSuite/Core/Component/User/Profile/Editor";
function setupUserList(userId: number, buttonId: string, className: string): void {
const button = document.getElementById(buttonId) as HTMLElement;
@@ -77,10 +78,8 @@ async function loadTabMenuContent(userId: number, tabName: string): Promise {
+async function reloadTabMenuContent(userId: number, tabName: string): Promise {
const container = document.querySelector('.tabMenuContent[data-name="' + tabName + '"]');
if (container === null) {
return;
@@ -92,9 +91,24 @@ export async function reloadTabMenuContent(userId: number, tabName: string): Pro
await loadTabMenuContent(userId, tabName);
}
-export function setup(userId: number): void {
+function setupEditor(userId: number, editOnInit: boolean): void {
+ const button = document.querySelector(".jsButtonEditProfile");
+ if (button === null) {
+ return;
+ }
+
+ setupProfileEditor(button, editOnInit, async () => {
+ // Reload the tab before selecting it, otherwise the select event would load
+ // the content of a tab that has not been loaded yet a second time.
+ await reloadTabMenuContent(userId, "about");
+ getTabMenu("profileContent")?.select("about");
+ });
+}
+
+export function setup(userId: number, editOnInit: boolean = false): void {
setupFollowingList(userId);
setupFollowerList(userId);
setupVisitorList(userId);
setupTabMenu(userId);
+ setupEditor(userId, editOnInit);
}
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/User/Profile/Editor.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/User/Profile/Editor.js
index b020585d828..3f46f6bf6a1 100644
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/User/Profile/Editor.js
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/User/Profile/Editor.js
@@ -1,29 +1,26 @@
/**
- * Edits the profile of a user in a dialog and reloads the "About" tab afterwards.
+ * Edits the profile of a user in a dialog.
*
* @author Marcel Werk
* @copyright 2001-2026 WoltLab GmbH
* @license GNU Lesser General Public License
* @since 6.3
*/
-define(["require", "exports", "WoltLabSuite/Core/Component/Dialog", "WoltLabSuite/Core/Component/Snackbar", "WoltLabSuite/Core/Controller/User/Profile", "WoltLabSuite/Core/Helper/PromiseMutex", "WoltLabSuite/Core/Ui/TabMenu"], function (require, exports, Dialog_1, Snackbar_1, Profile_1, PromiseMutex_1, TabMenu_1) {
+define(["require", "exports", "WoltLabSuite/Core/Component/Dialog", "WoltLabSuite/Core/Component/Snackbar", "WoltLabSuite/Core/Helper/PromiseMutex"], function (require, exports, Dialog_1, Snackbar_1, PromiseMutex_1) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.setup = setup;
- async function editProfile(userId, endpoint) {
+ async function editProfile(endpoint, onSuccess) {
const { ok } = await (0, Dialog_1.dialogFactory)().usingFormBuilder().fromEndpoint(endpoint);
if (!ok) {
return;
}
- // Reload the tab before selecting it, otherwise the select event would load
- // the content of a tab that has not been loaded yet a second time.
- await (0, Profile_1.reloadTabMenuContent)(userId, "about");
- (0, TabMenu_1.getTabMenu)("profileContent")?.select("about");
+ await onSuccess();
(0, Snackbar_1.showDefaultSuccessSnackbar)();
}
- function setup(button, userId, editOnInit) {
+ function setup(button, editOnInit, onSuccess) {
const endpoint = button.dataset.endpoint;
- const openDialog = (0, PromiseMutex_1.promiseMutex)(() => editProfile(userId, endpoint));
+ const openDialog = (0, PromiseMutex_1.promiseMutex)(() => editProfile(endpoint, onSuccess));
button.addEventListener("click", () => openDialog());
if (editOnInit) {
openDialog();
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Controller/User/Profile.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Controller/User/Profile.js
index b33f9eef042..4af7e0bb346 100644
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Controller/User/Profile.js
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Controller/User/Profile.js
@@ -5,10 +5,9 @@
* @copyright 2001-2022 WoltLab GmbH
* @license GNU Lesser General Public License
*/
-define(["require", "exports", "tslib", "WoltLabSuite/Core/Event/Handler", "WoltLabSuite/Core/Dom/Util", "WoltLabSuite/Core/Dom/Change/Listener", "WoltLabSuite/Core/Ui/TabMenu", "WoltLabSuite/Core/Helper/PromiseMutex", "WoltLabSuite/Core/Component/Dialog", "WoltLabSuite/Core/Api/Users/RenderUserProfileMenuContent"], function (require, exports, tslib_1, EventHandler, Util_1, Listener_1, TabMenu_1, PromiseMutex_1, Dialog_1, RenderUserProfileMenuContent_1) {
+define(["require", "exports", "tslib", "WoltLabSuite/Core/Event/Handler", "WoltLabSuite/Core/Dom/Util", "WoltLabSuite/Core/Dom/Change/Listener", "WoltLabSuite/Core/Ui/TabMenu", "WoltLabSuite/Core/Helper/PromiseMutex", "WoltLabSuite/Core/Component/Dialog", "WoltLabSuite/Core/Api/Users/RenderUserProfileMenuContent", "WoltLabSuite/Core/Component/User/Profile/Editor"], function (require, exports, tslib_1, EventHandler, Util_1, Listener_1, TabMenu_1, PromiseMutex_1, Dialog_1, RenderUserProfileMenuContent_1, Editor_1) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
- exports.reloadTabMenuContent = reloadTabMenuContent;
exports.setup = setup;
EventHandler = tslib_1.__importStar(EventHandler);
function setupUserList(userId, buttonId, className) {
@@ -57,8 +56,6 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Event/Handler", "WoltL
}
/**
* Replaces the content of the given tab with its current content.
- *
- * @since 6.3
*/
async function reloadTabMenuContent(userId, tabName) {
const container = document.querySelector('.tabMenuContent[data-name="' + tabName + '"]');
@@ -69,10 +66,23 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Event/Handler", "WoltL
container.innerHTML = "";
await loadTabMenuContent(userId, tabName);
}
- function setup(userId) {
+ function setupEditor(userId, editOnInit) {
+ const button = document.querySelector(".jsButtonEditProfile");
+ if (button === null) {
+ return;
+ }
+ (0, Editor_1.setup)(button, editOnInit, async () => {
+ // Reload the tab before selecting it, otherwise the select event would load
+ // the content of a tab that has not been loaded yet a second time.
+ await reloadTabMenuContent(userId, "about");
+ (0, TabMenu_1.getTabMenu)("profileContent")?.select("about");
+ });
+ }
+ function setup(userId, editOnInit = false) {
setupFollowingList(userId);
setupFollowerList(userId);
setupVisitorList(userId);
setupTabMenu(userId);
+ setupEditor(userId, editOnInit);
}
});
From 749a2298e826f3273dfcfd7f6b9ff82cfa9d715c Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Sat, 3 Oct 2026 19:44:41 +0200
Subject: [PATCH 012/115] Remove redundant `wcf.user.activityPoint` jsphrase
from profile template (already preloaded globally)
---
com.woltlab.wcf/templates/user.tpl | 4 ----
1 file changed, 4 deletions(-)
diff --git a/com.woltlab.wcf/templates/user.tpl b/com.woltlab.wcf/templates/user.tpl
index 2e67f50a38f..d37fa4490ee 100644
--- a/com.woltlab.wcf/templates/user.tpl
+++ b/com.woltlab.wcf/templates/user.tpl
@@ -42,10 +42,6 @@
{/if}
$(function() {
- {if !$__wcf->getUser()->isGuest() && $__wcf->getUser()->userID != $user->userID}
- {jsphrase name='wcf.user.activityPoint'}
- {/if}
-
{event name='javascriptInit'}
});
From e0d63908e7c01eaf91516d682d87ad4a10f10037 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Sat, 3 Oct 2026 20:27:33 +0200
Subject: [PATCH 013/115] Replace frontend user search with user option filters
on the members list
---
com.woltlab.wcf/fileDelete.xml | 2 +
com.woltlab.wcf/menuItem.xml | 6 -
com.woltlab.wcf/page.xml | 17 +-
com.woltlab.wcf/templateDelete.xml | 7 +
.../birthdaySearchableOptionType.tpl | 14 --
com.woltlab.wcf/templates/membersList.tpl | 9 -
.../multiSelectSearchableOptionType.tpl | 18 --
.../templates/selectSearchableOptionType.tpl | 19 --
.../templates/textSearchableOptionType.tpl | 35 ---
.../textareaSearchableOptionType.tpl | 14 --
.../templates/userOptionFieldList.tpl | 19 --
com.woltlab.wcf/templates/userSearch.tpl | 47 ----
phpstan-baseline.neon | 12 -
.../files/lib/form/UserSearchForm.class.php | 221 ------------------
.../files/lib/page/MembersListPage.class.php | 46 ----
.../listView/user/MembersListView.class.php | 44 ++++
.../user/UserSearchResultsListView.class.php | 60 -----
.../option/ISearchableUserOption.class.php | 1 +
.../view/filter/UserOptionFilter.class.php | 70 ++++++
wcfsetup/install/lang/de.xml | 6 +-
wcfsetup/install/lang/en.xml | 6 +-
21 files changed, 132 insertions(+), 541 deletions(-)
delete mode 100644 com.woltlab.wcf/templates/birthdaySearchableOptionType.tpl
delete mode 100644 com.woltlab.wcf/templates/multiSelectSearchableOptionType.tpl
delete mode 100644 com.woltlab.wcf/templates/selectSearchableOptionType.tpl
delete mode 100644 com.woltlab.wcf/templates/textSearchableOptionType.tpl
delete mode 100644 com.woltlab.wcf/templates/textareaSearchableOptionType.tpl
delete mode 100644 com.woltlab.wcf/templates/userOptionFieldList.tpl
delete mode 100644 com.woltlab.wcf/templates/userSearch.tpl
delete mode 100644 wcfsetup/install/files/lib/form/UserSearchForm.class.php
delete mode 100644 wcfsetup/install/files/lib/system/listView/user/UserSearchResultsListView.class.php
create mode 100644 wcfsetup/install/files/lib/system/view/filter/UserOptionFilter.class.php
diff --git a/com.woltlab.wcf/fileDelete.xml b/com.woltlab.wcf/fileDelete.xml
index b75e6e2cbe4..f8566e7abd3 100644
--- a/com.woltlab.wcf/fileDelete.xml
+++ b/com.woltlab.wcf/fileDelete.xml
@@ -8,6 +8,8 @@
js/WoltLabSuite/Core/Ui/User/CoverPhoto/Delete.js
js/WoltLabSuite/Core/Ui/User/CoverPhoto/Upload.js
lib/form/AvatarEditForm.class.php
+ lib/form/UserSearchForm.class.php
+ lib/system/listView/user/UserSearchResultsListView.class.php
lib/system/api/cuyz/valinor/qa/PHPStan/Stubs/Psr/SimpleCache/CacheInterface.stub
lib/system/api/cuyz/valinor/src/Cache/ChainCache.php
lib/system/api/cuyz/valinor/src/Cache/WarmupCache.php
diff --git a/com.woltlab.wcf/menuItem.xml b/com.woltlab.wcf/menuItem.xml
index 10d767af59e..438cdf36f49 100644
--- a/com.woltlab.wcf/menuItem.xml
+++ b/com.woltlab.wcf/menuItem.xml
@@ -49,12 +49,6 @@
Staff
com.woltlab.wcf.Team
- -
-
com.woltlab.wcf.MembersList
- Mitgliedersuche
- Search Members
- com.woltlab.wcf.UserSearch
-
-
com.woltlab.wcf.FooterMenu
Datenschutzerklärung
diff --git a/com.woltlab.wcf/page.xml b/com.woltlab.wcf/page.xml
index c3acbad30a7..47d092f9306 100644
--- a/com.woltlab.wcf/page.xml
+++ b/com.woltlab.wcf/page.xml
@@ -59,21 +59,6 @@
Team
-
- system
- wcf\form\UserSearchForm
- Benutzer suchen
- Search Users
- com.woltlab.wcf.MembersList
- module_members_list
- user.profile.canViewMembersList
-
- Search Users
-
-
- Benutzer suchen
-
-
system
wcf\form\AccountManagementForm
@@ -915,5 +900,7 @@ E-Mail: [E-Mail-Adresse der verantwortlichen Stelle]
Verantwortliche Stell
+
+
diff --git a/com.woltlab.wcf/templateDelete.xml b/com.woltlab.wcf/templateDelete.xml
index 8cb59f7173c..4c1b5aecbc0 100644
--- a/com.woltlab.wcf/templateDelete.xml
+++ b/com.woltlab.wcf/templateDelete.xml
@@ -120,5 +120,12 @@
avatarEdit
userProfileAboutEditable
userProfileOptionFieldList
+ userSearch
+ userOptionFieldList
+ birthdaySearchableOptionType
+ multiSelectSearchableOptionType
+ selectSearchableOptionType
+ textSearchableOptionType
+ textareaSearchableOptionType
diff --git a/com.woltlab.wcf/templates/birthdaySearchableOptionType.tpl b/com.woltlab.wcf/templates/birthdaySearchableOptionType.tpl
deleted file mode 100644
index 379597d50e5..00000000000
--- a/com.woltlab.wcf/templates/birthdaySearchableOptionType.tpl
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
diff --git a/com.woltlab.wcf/templates/membersList.tpl b/com.woltlab.wcf/templates/membersList.tpl
index 9d28d5f31da..ba7e8609200 100644
--- a/com.woltlab.wcf/templates/membersList.tpl
+++ b/com.woltlab.wcf/templates/membersList.tpl
@@ -1,12 +1,3 @@
-{if $searchID}
- {capture assign='pageTitle'}{lang}wcf.user.search.results{/lang}{/capture}
- {capture assign='contentTitle'}{lang}wcf.user.search.results{/lang}{/capture}
-{/if}
-
-{capture assign='contentInteractionButtons'}
- {icon name='search'} {lang}wcf.user.search{/lang}
-{/capture}
-
{include file='header'}
diff --git a/com.woltlab.wcf/templates/multiSelectSearchableOptionType.tpl b/com.woltlab.wcf/templates/multiSelectSearchableOptionType.tpl
deleted file mode 100644
index e4d9b4b307f..00000000000
--- a/com.woltlab.wcf/templates/multiSelectSearchableOptionType.tpl
+++ /dev/null
@@ -1,18 +0,0 @@
-
{lang}wcf.user.option.searchRadioButtonOption{/lang}
-
required} required{/if}>
- {foreach from=$selectOptions key=key item=selectOption}
- {lang}{$selectOption}{/lang}
- {/foreach}
-
-
-
diff --git a/com.woltlab.wcf/templates/selectSearchableOptionType.tpl b/com.woltlab.wcf/templates/selectSearchableOptionType.tpl
deleted file mode 100644
index e05649bc5e5..00000000000
--- a/com.woltlab.wcf/templates/selectSearchableOptionType.tpl
+++ /dev/null
@@ -1,19 +0,0 @@
-
{lang}wcf.user.option.searchRadioButtonOption{/lang}
-
required} required{/if}>
- {if !$allowEmptyValue|empty}{lang}wcf.global.noSelection{/lang} {/if}
- {foreach from=$selectOptions key=key item=selectOption}
- {lang}{$selectOption}{/lang}
- {/foreach}
-
-
-
diff --git a/com.woltlab.wcf/templates/textSearchableOptionType.tpl b/com.woltlab.wcf/templates/textSearchableOptionType.tpl
deleted file mode 100644
index 9bfdfbaa309..00000000000
--- a/com.woltlab.wcf/templates/textSearchableOptionType.tpl
+++ /dev/null
@@ -1,35 +0,0 @@
-
{lang}wcf.user.option.searchTextOption{/lang}
-
required} required{/if}>
-
-
diff --git a/com.woltlab.wcf/templates/textareaSearchableOptionType.tpl b/com.woltlab.wcf/templates/textareaSearchableOptionType.tpl
deleted file mode 100644
index d98eb4d79fc..00000000000
--- a/com.woltlab.wcf/templates/textareaSearchableOptionType.tpl
+++ /dev/null
@@ -1,14 +0,0 @@
-
{lang}wcf.user.option.searchTextOption{/lang}
-
-
-
diff --git a/com.woltlab.wcf/templates/userOptionFieldList.tpl b/com.woltlab.wcf/templates/userOptionFieldList.tpl
deleted file mode 100644
index 4af1dcf5bde..00000000000
--- a/com.woltlab.wcf/templates/userOptionFieldList.tpl
+++ /dev/null
@@ -1,19 +0,0 @@
-{foreach from=$options item=optionData}
- {assign var=option value=$optionData[object]}
-
- {if $isSearchMode|empty || !$optionData[hideLabelInSearch]}{$option->getTitle()} {if $isSearchMode|empty && $option->required} * {/if}{/if}
- {unsafe:$optionData[html]}
- {$option->getDescription()}
-
- {if $errorType|is_array && $errorType[$option->optionName]|isset}
-
- {if $errorType[$option->optionName] == 'empty'}
- {lang}wcf.global.form.error.empty{/lang}
- {else}
- {lang}{$langPrefix}error.{$errorType[$option->optionName]}{/lang}
- {/if}
-
- {/if}
-
-
-{/foreach}
diff --git a/com.woltlab.wcf/templates/userSearch.tpl b/com.woltlab.wcf/templates/userSearch.tpl
deleted file mode 100644
index f48b5dd2dc7..00000000000
--- a/com.woltlab.wcf/templates/userSearch.tpl
+++ /dev/null
@@ -1,47 +0,0 @@
-{include file='header'}
-
-{if $errorField == 'search'}
-
{lang}wcf.user.search.error.noMatches{/lang}
-{else}
- {include file='shared_formError'}
-{/if}
-
-
-
-
-
-{include file='footer'}
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
index 299a0464995..23e6e51601b 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -787,12 +787,6 @@ parameters:
count: 1
path: wcfsetup/install/files/lib/form/TagSearchForm.class.php
- -
- message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
- identifier: empty.notAllowed
- count: 1
- path: wcfsetup/install/files/lib/form/UserSearchForm.class.php
-
-
message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
identifier: empty.notAllowed
@@ -811,12 +805,6 @@ parameters:
count: 1
path: wcfsetup/install/files/lib/page/ContactAttachmentPage.class.php
- -
- message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
- identifier: empty.notAllowed
- count: 1
- path: wcfsetup/install/files/lib/page/MembersListPage.class.php
-
-
message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
identifier: empty.notAllowed
diff --git a/wcfsetup/install/files/lib/form/UserSearchForm.class.php b/wcfsetup/install/files/lib/form/UserSearchForm.class.php
deleted file mode 100644
index da9811ff99e..00000000000
--- a/wcfsetup/install/files/lib/form/UserSearchForm.class.php
+++ /dev/null
@@ -1,221 +0,0 @@
-
- *
- * @property UserOptionHandler $optionHandler
- */
-class UserSearchForm extends UserOptionListForm
-{
- /**
- * @inheritDoc
- */
- public $neededModules = ['MODULE_MEMBERS_LIST'];
-
- /**
- * username
- * @var string
- */
- public $username = '';
-
- /**
- * matches
- * @var int[]
- */
- public $matches = [];
-
- /**
- * condition builder object
- * @var ?PreparedStatementConditionBuilder
- */
- public $conditions;
-
- /**
- * search id
- * @var int
- */
- public $searchID = 0;
-
- /**
- * number of results
- * @var int
- */
- public $maxResults = 1000;
-
- /**
- * option tree
- * @var mixed[]
- */
- public $optionTree = [];
-
- #[\Override]
- public function readFormParameters()
- {
- parent::readFormParameters();
-
- if (isset($_POST['username'])) {
- $this->username = StringUtil::trim($_POST['username']);
- }
- }
-
- #[\Override]
- protected function initOptionHandler()
- {
- $this->optionHandler->enableSearchMode();
- $this->optionHandler->init();
- }
-
- #[\Override]
- public function readData()
- {
- parent::readData();
-
- if ($this->hasPsr7Response()) {
- return;
- }
-
- $this->readOptionTree();
-
- // add breadcrumbs
- if (\MODULE_MEMBERS_LIST !== 0) {
- PageLocationManager::getInstance()->addParentLocation('com.woltlab.wcf.MembersList');
- }
- }
-
- /**
- * Reads option tree on page init.
- *
- * @return void
- */
- protected function readOptionTree()
- {
- $this->optionTree = $this->optionHandler->getOptionTree();
- }
-
- #[\Override]
- public function assignVariables()
- {
- parent::assignVariables();
-
- WCF::getTPL()->assign([
- 'username' => $this->username,
- 'optionTree' => $this->optionTree,
- ]);
- }
-
- #[\Override]
- public function save()
- {
- parent::save();
-
- // store search result in database
- $search = SearchEditor::create([
- 'userID' => WCF::getUser()->userID ?: null,
- 'searchData' => \serialize(['matches' => $this->matches]),
- 'searchTime' => \TIME_NOW,
- 'searchType' => 'users',
- ]);
-
- // get new search id
- $this->searchID = $search->searchID;
- $this->saved();
-
- // forward to result page
- $url = LinkHandler::getInstance()->getLink('MembersList', ['id' => $this->searchID]);
- $this->setPsr7Response(new RedirectResponse($url, 303));
- }
-
- #[\Override]
- public function validate()
- {
- AbstractForm::validate();
-
- // do search
- $this->search();
-
- if ($this->matches === []) {
- throw new UserInputException('search', 'noMatches');
- }
- }
-
- /**
- * Search for users which fit to the search values.
- *
- * @return void
- */
- protected function search()
- {
- $this->matches = [];
-
- // build search condition
- $this->conditions = new PreparedStatementConditionBuilder();
-
- // static fields
- $this->buildStaticConditions();
-
- // dynamic fields
- $this->buildDynamicConditions();
-
- // if no conditions exists, no need to send query
- if ($this->conditions->__toString() === '') {
- return;
- }
-
- // perform search
- $sql = "SELECT user_table.userID
- FROM wcf1_user user_table
- LEFT JOIN wcf1_user_option_value option_value
- ON option_value.userID = user_table.userID
- {$this->conditions}";
- $statement = WCF::getDB()->prepare($sql, $this->maxResults);
- $statement->execute($this->conditions->getParameters());
- $this->matches = $statement->fetchAll(\PDO::FETCH_COLUMN);
- }
-
- /**
- * Builds the static conditions.
- *
- * @return void
- */
- protected function buildStaticConditions()
- {
- if (!empty($this->username)) {
- $this->conditions->add("user_table.username LIKE ?", ['%' . WCF::getDB()->escapeLikeValue($this->username) . '%']);
- }
- }
-
- /**
- * Builds the dynamic conditions.
- *
- * @return void
- */
- protected function buildDynamicConditions()
- {
- foreach ($this->optionHandler->getCategoryOptions('profile') as $option) {
- $option = $option['object'];
-
- $value = $this->optionHandler->optionValues[$option->optionName] ?? null;
- $typeObject = $this->optionHandler->getTypeObject($option->optionType);
- \assert($typeObject instanceof ISearchableUserOption);
- $typeObject->getCondition($this->conditions, $option, $value);
- }
- }
-}
diff --git a/wcfsetup/install/files/lib/page/MembersListPage.class.php b/wcfsetup/install/files/lib/page/MembersListPage.class.php
index 8aadb92392c..2c937518e5a 100644
--- a/wcfsetup/install/files/lib/page/MembersListPage.class.php
+++ b/wcfsetup/install/files/lib/page/MembersListPage.class.php
@@ -2,11 +2,7 @@
namespace wcf\page;
-use wcf\data\search\Search;
-use wcf\system\exception\IllegalLinkException;
use wcf\system\listView\user\MembersListView;
-use wcf\system\listView\user\UserSearchResultsListView;
-use wcf\system\WCF;
/**
* Shows members page.
@@ -29,57 +25,15 @@ class MembersListPage extends AbstractListViewPage
*/
public $neededModules = ['MODULE_MEMBERS_LIST'];
- public ?int $searchID = null;
-
/**
* @var list
* @deprecated 6.2 No longer in use, but here for backwards compatibility.
*/
public $validSortFields = ['username', 'registrationDate', 'activityPoints', 'likesReceived', 'lastActivityTime'];
- #[\Override]
- public function readParameters()
- {
- parent::readParameters();
-
- if (!empty($_REQUEST['id'])) {
- $this->searchID = \intval($_REQUEST['id']);
- $search = new Search($this->searchID);
- if ($search->isNil() || $search->userID !== WCF::getUser()->userID || $search->searchType !== 'users') {
- throw new IllegalLinkException();
- }
- }
- }
-
#[\Override]
protected function createListView(): MembersListView
{
- if ($this->searchID !== null) {
- return new UserSearchResultsListView($this->searchID);
- }
-
return new MembersListView();
}
-
- #[\Override]
- public function assignVariables()
- {
- parent::assignVariables();
-
- WCF::getTPL()->assign([
- 'searchID' => $this->searchID,
- ]);
- }
-
- #[\Override]
- protected function getBaseUrlParameters(): array
- {
- if ($this->searchID !== null) {
- return [
- 'id' => $this->searchID,
- ];
- }
-
- return parent::getBaseUrlParameters();
- }
}
diff --git a/wcfsetup/install/files/lib/system/listView/user/MembersListView.class.php b/wcfsetup/install/files/lib/system/listView/user/MembersListView.class.php
index 21af71477dd..740d940b074 100644
--- a/wcfsetup/install/files/lib/system/listView/user/MembersListView.class.php
+++ b/wcfsetup/install/files/lib/system/listView/user/MembersListView.class.php
@@ -3,14 +3,17 @@
namespace wcf\system\listView\user;
use wcf\data\DatabaseObjectList;
+use wcf\data\user\option\UserOption;
use wcf\data\user\UserProfile;
use wcf\data\user\UserProfileList;
use wcf\event\listView\user\MembersListViewInitialized;
use wcf\system\interaction\user\UserProfileInteractions;
use wcf\system\listView\AbstractListView;
+use wcf\system\option\user\TUserOptionsByCategory;
use wcf\system\view\filter\exception\InvalidFilterValue;
use wcf\system\view\filter\SelectFilter;
use wcf\system\view\filter\TextFilter;
+use wcf\system\view\filter\UserOptionFilter;
use wcf\system\listView\ListViewSortField;
use wcf\system\user\storage\UserStorageHandler;
use wcf\system\WCF;
@@ -27,6 +30,8 @@
*/
class MembersListView extends AbstractListView
{
+ use TUserOptionsByCategory;
+
private bool $userStoragePreloaded = false;
public function __construct()
@@ -42,6 +47,7 @@ public function __construct()
$this->addAvailableFilters([
$this->getFirstLetterFilter(),
new TextFilter('username', 'wcf.user.username'),
+ ...$this->getUserOptionFilters(),
]);
$this->setInteractionProvider(new UserProfileInteractions());
@@ -94,6 +100,44 @@ protected function getInitializedEvent(): MembersListViewInitialized
return new MembersListViewInitialized($this);
}
+ /**
+ * Returns the filters for the searchable user options that are visible to
+ * the active user.
+ *
+ * @return list
+ * @since 6.3
+ */
+ protected function getUserOptionFilters(): array
+ {
+ $options = $this->getUserOptionsByCategory('profile', static function (UserOption $option) {
+ if ($option->searchable === 0 || !$option->validateOptions() || !$option->validatePermissions()) {
+ return false;
+ }
+
+ // Filtering must not reveal values that are only visible to the owner
+ // of the option, thus the visibility is checked without an owner.
+ $option = clone $option;
+ $option->user = null;
+ if (!$option->isVisible()) {
+ return false;
+ }
+
+ if (!$option->getFormOption()->isFilterable()) {
+ return false;
+ }
+
+ // Date options store `Y-m-d` instead of the timestamps expected by the
+ // date range filter, filtering by the birthday would also reveal the
+ // year of birth if it is hidden.
+ return !\in_array($option->optionType, ['birthday', 'date'], true);
+ });
+
+ return \array_map(
+ static fn(UserOption $option) => new UserOptionFilter($option),
+ \array_merge(...\array_values($options))
+ );
+ }
+
protected function getFirstLetterFilter(): SelectFilter
{
$availableLetters = \str_split('#ABCDEFGHIJKLMNOPQRSTUVWXYZ');
diff --git a/wcfsetup/install/files/lib/system/listView/user/UserSearchResultsListView.class.php b/wcfsetup/install/files/lib/system/listView/user/UserSearchResultsListView.class.php
deleted file mode 100644
index e2ddce4352a..00000000000
--- a/wcfsetup/install/files/lib/system/listView/user/UserSearchResultsListView.class.php
+++ /dev/null
@@ -1,60 +0,0 @@
-
- * @since 6.2
- */
-class UserSearchResultsListView extends MembersListView
-{
- private Search $search;
-
- public function __construct(private readonly int $searchID)
- {
- parent::__construct();
-
- $this->search = new Search($this->searchID);
- }
-
- #[\Override]
- protected function createObjectList(): UserProfileList
- {
- $list = parent::createObjectList();
-
- $searchData = \unserialize($this->search->searchData);
- $list->getConditionBuilder()->add("user_table.userID IN (?)", [$searchData['matches']]);
-
- return $list;
- }
-
- #[\Override]
- public function isAccessible(): bool
- {
- if (!parent::isAccessible()) {
- return false;
- }
-
- if ($this->search->isNil() || $this->search->userID !== WCF::getUser()->userID || $this->search->searchType !== 'users') {
- return false;
- }
-
- return true;
- }
-
- #[\Override]
- public function getParameters(): array
- {
- return [
- 'searchID' => $this->searchID,
- ];
- }
-}
diff --git a/wcfsetup/install/files/lib/system/option/ISearchableUserOption.class.php b/wcfsetup/install/files/lib/system/option/ISearchableUserOption.class.php
index 2043693e143..432533e1acd 100644
--- a/wcfsetup/install/files/lib/system/option/ISearchableUserOption.class.php
+++ b/wcfsetup/install/files/lib/system/option/ISearchableUserOption.class.php
@@ -25,6 +25,7 @@ public function getSearchFormElement(Option $option, mixed $value);
* Returns a condition for search sql query.
*
* @return bool
+ * @deprecated 6.3 No longer in use.
*/
public function getCondition(PreparedStatementConditionBuilder &$conditions, Option $option, mixed $value);
}
diff --git a/wcfsetup/install/files/lib/system/view/filter/UserOptionFilter.class.php b/wcfsetup/install/files/lib/system/view/filter/UserOptionFilter.class.php
new file mode 100644
index 00000000000..db11776f8cd
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/view/filter/UserOptionFilter.class.php
@@ -0,0 +1,70 @@
+
+ * @since 6.3
+ */
+final class UserOptionFilter extends AbstractFilter
+{
+ private const JOIN_CONDITION = "
+ LEFT JOIN wcf1_user_option_value user_option_value
+ ON user_option_value.userID = user_table.userID";
+
+ public function __construct(
+ private readonly UserOption $option
+ ) {
+ parent::__construct(
+ $option->getIdentifier(),
+ 'wcf.user.option.' . $option->optionName,
+ 'user_option_value.' . $option->getIdentifier()
+ );
+ }
+
+ #[\Override]
+ public function getFormField(): AbstractFormField
+ {
+ return $this->option->getFormOption()
+ ->getFilterFormField($this->id, $this->option->getConfiguration())
+ ->label($this->languageItem);
+ }
+
+ #[\Override]
+ public function applyFilter(DatabaseObjectList $list, string $value): void
+ {
+ // `UserList` only joins the user option values for reading the objects,
+ // but not for counting them or reading their ids.
+ if (!\str_contains($list->sqlConditionJoins, 'wcf1_user_option_value user_option_value')) {
+ $list->sqlConditionJoins .= self::JOIN_CONDITION;
+ }
+
+ $this->option->getFormOption()->applyFilter($list, $this->getDatabaseColumnName($list), $value);
+ }
+
+ #[\Override]
+ public function renderValue(string $value): string
+ {
+ return $this->option->getFormOption()->renderFilterValue($value, $this->option->getConfiguration());
+ }
+
+ #[\Override]
+ public function serializeValue(mixed $value): string
+ {
+ return $this->option->getFormOption()->serializeValue($value);
+ }
+
+ #[\Override]
+ public function unserializeValue(string $value): mixed
+ {
+ return $this->option->getFormOption()->unserializeValue($value);
+ }
+}
diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml
index 85a2197c493..6626e7ba1a9 100644
--- a/wcfsetup/install/lang/de.xml
+++ b/wcfsetup/install/lang/de.xml
@@ -4778,10 +4778,7 @@ sich{/if} nicht bei uns registriert {if LANGUAGE_USE_INFORMAL_VARIANT}hast{else}
-
-
-
@@ -5798,5 +5795,8 @@ Erlaubte Dateiendungen: {', '|implode:$allowedFileExtensions}]]>
+
+
+
diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml
index 587c1bf92d2..d06b83af2a0 100644
--- a/wcfsetup/install/lang/en.xml
+++ b/wcfsetup/install/lang/en.xml
@@ -4774,10 +4774,7 @@ not register with us.]]>
-
-
-
@@ -5800,5 +5797,8 @@ Allowed extensions: {', '|implode:$allowedFileExtensions}]]>
+
+
+
From 6f33dc1078aeafdce0dd501d1716fd5e2670bc84 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Sat, 3 Oct 2026 21:21:06 +0200
Subject: [PATCH 014/115] Add age filter
---
.../listView/user/MembersListView.class.php | 14 ++-
.../view/filter/UserAgeFilter.class.php | 98 +++++++++++++++++++
.../view/filter/UserOptionFilter.class.php | 28 ++++--
3 files changed, 126 insertions(+), 14 deletions(-)
create mode 100644 wcfsetup/install/files/lib/system/view/filter/UserAgeFilter.class.php
diff --git a/wcfsetup/install/files/lib/system/listView/user/MembersListView.class.php b/wcfsetup/install/files/lib/system/listView/user/MembersListView.class.php
index 740d940b074..7c297cdc6cd 100644
--- a/wcfsetup/install/files/lib/system/listView/user/MembersListView.class.php
+++ b/wcfsetup/install/files/lib/system/listView/user/MembersListView.class.php
@@ -13,6 +13,7 @@
use wcf\system\view\filter\exception\InvalidFilterValue;
use wcf\system\view\filter\SelectFilter;
use wcf\system\view\filter\TextFilter;
+use wcf\system\view\filter\UserAgeFilter;
use wcf\system\view\filter\UserOptionFilter;
use wcf\system\listView\ListViewSortField;
use wcf\system\user\storage\UserStorageHandler;
@@ -104,7 +105,7 @@ protected function getInitializedEvent(): MembersListViewInitialized
* Returns the filters for the searchable user options that are visible to
* the active user.
*
- * @return list
+ * @return list
* @since 6.3
*/
protected function getUserOptionFilters(): array
@@ -127,13 +128,16 @@ protected function getUserOptionFilters(): array
}
// Date options store `Y-m-d` instead of the timestamps expected by the
- // date range filter, filtering by the birthday would also reveal the
- // year of birth if it is hidden.
- return !\in_array($option->optionType, ['birthday', 'date'], true);
+ // date range filter. The birthday is filtered by the age instead, which
+ // respects users who hide the year of their birth.
+ return $option->optionType !== 'date';
});
return \array_map(
- static fn(UserOption $option) => new UserOptionFilter($option),
+ static fn(UserOption $option) => match ($option->optionType) {
+ 'birthday' => new UserAgeFilter($option),
+ default => new UserOptionFilter($option),
+ },
\array_merge(...\array_values($options))
);
}
diff --git a/wcfsetup/install/files/lib/system/view/filter/UserAgeFilter.class.php b/wcfsetup/install/files/lib/system/view/filter/UserAgeFilter.class.php
new file mode 100644
index 00000000000..94203543c8f
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/view/filter/UserAgeFilter.class.php
@@ -0,0 +1,98 @@
+
+ * @since 6.3
+ */
+final class UserAgeFilter extends IntegerFilter
+{
+ private const MAXIMUM_AGE = 120;
+
+ public function __construct(UserOption $birthdayOption)
+ {
+ parent::__construct(
+ 'age',
+ 'wcf.user.birthday.age',
+ 'user_option_value.' . $birthdayOption->getIdentifier(),
+ minimum: 0,
+ maximum: self::MAXIMUM_AGE
+ );
+ }
+
+ #[\Override]
+ public function applyFilter(DatabaseObjectList $list, string $value): void
+ {
+ $values = \explode(';', $value);
+ $ageFrom = \count($values) === 2 ? (int)$values[0] : 0;
+ $ageTo = \count($values) === 2 ? (int)$values[1] : 0;
+
+ if (
+ ($ageFrom === 0 && $ageTo === 0)
+ || $ageFrom < 0
+ || $ageTo < 0
+ || $ageFrom > self::MAXIMUM_AGE
+ || $ageTo > self::MAXIMUM_AGE
+ ) {
+ throw new InvalidFilterValue("Invalid value '{$value}' for filter '{$this->id}' given.");
+ }
+
+ $this->joinUserOptionValues($list);
+
+ $showYearOptionID = User::getUserOptionID('birthdayShowYear');
+ if ($showYearOptionID !== null) {
+ $list->getConditionBuilder()->add("user_option_value.userOption{$showYearOptionID} = ?", [1]);
+ }
+
+ // The birthday is stored as `Y-m-d`, which sorts chronologically. Users
+ // without a birthday store either an empty string or `0000-00-00`.
+ $columnName = $this->getDatabaseColumnName($list);
+ $list->getConditionBuilder()->add("{$columnName} > ?", ['0000-00-00']);
+
+ if ($ageFrom !== 0) {
+ $latestBirthday = DateUtil::getDateTimeByTimestamp(\TIME_NOW)
+ ->sub(new \DateInterval("P{$ageFrom}Y"));
+
+ $list->getConditionBuilder()->add("{$columnName} <= ?", [$latestBirthday->format('Y-m-d')]);
+ }
+
+ if ($ageTo !== 0) {
+ $earliestBirthday = DateUtil::getDateTimeByTimestamp(\TIME_NOW)
+ ->sub(new \DateInterval('P' . ($ageTo + 1) . 'Y'))
+ ->add(new \DateInterval('P1D'));
+
+ $list->getConditionBuilder()->add("{$columnName} >= ?", [$earliestBirthday->format('Y-m-d')]);
+ }
+ }
+
+ /**
+ * Joins the user option values when counting the objects or reading their
+ * ids, `UserList` only joins them when reading the objects.
+ *
+ * @param DatabaseObjectList $list
+ */
+ private function joinUserOptionValues(DatabaseObjectList $list): void
+ {
+ if (\str_contains($list->sqlConditionJoins, 'wcf1_user_option_value user_option_value')) {
+ return;
+ }
+
+ $list->sqlConditionJoins .= "
+ LEFT JOIN wcf1_user_option_value user_option_value
+ ON user_option_value.userID = user_table.userID";
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/view/filter/UserOptionFilter.class.php b/wcfsetup/install/files/lib/system/view/filter/UserOptionFilter.class.php
index db11776f8cd..639ccf9c563 100644
--- a/wcfsetup/install/files/lib/system/view/filter/UserOptionFilter.class.php
+++ b/wcfsetup/install/files/lib/system/view/filter/UserOptionFilter.class.php
@@ -2,6 +2,7 @@
namespace wcf\system\view\filter;
+use wcf\data\DatabaseObject;
use wcf\data\DatabaseObjectList;
use wcf\data\user\option\UserOption;
use wcf\system\form\builder\field\AbstractFormField;
@@ -16,10 +17,6 @@
*/
final class UserOptionFilter extends AbstractFilter
{
- private const JOIN_CONDITION = "
- LEFT JOIN wcf1_user_option_value user_option_value
- ON user_option_value.userID = user_table.userID";
-
public function __construct(
private readonly UserOption $option
) {
@@ -41,11 +38,7 @@ public function getFormField(): AbstractFormField
#[\Override]
public function applyFilter(DatabaseObjectList $list, string $value): void
{
- // `UserList` only joins the user option values for reading the objects,
- // but not for counting them or reading their ids.
- if (!\str_contains($list->sqlConditionJoins, 'wcf1_user_option_value user_option_value')) {
- $list->sqlConditionJoins .= self::JOIN_CONDITION;
- }
+ $this->joinUserOptionValues($list);
$this->option->getFormOption()->applyFilter($list, $this->getDatabaseColumnName($list), $value);
}
@@ -67,4 +60,21 @@ public function unserializeValue(string $value): mixed
{
return $this->option->getFormOption()->unserializeValue($value);
}
+
+ /**
+ * Joins the user option values when counting the objects or reading their
+ * ids, `UserList` only joins them when reading the objects.
+ *
+ * @param DatabaseObjectList $list
+ */
+ private function joinUserOptionValues(DatabaseObjectList $list): void
+ {
+ if (\str_contains($list->sqlConditionJoins, 'wcf1_user_option_value user_option_value')) {
+ return;
+ }
+
+ $list->sqlConditionJoins .= "
+ LEFT JOIN wcf1_user_option_value user_option_value
+ ON user_option_value.userID = user_table.userID";
+ }
}
From d8794fa0a10873d1e2479718cd3d444b4980e9c5 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Sun, 4 Oct 2026 13:10:16 +0200
Subject: [PATCH 015/115] Move registration username/email validation to RPC
endpoints, enforce force login per API endpoint
---
com.woltlab.wcf/fileDelete.xml | 2 +
.../Core/Api/Users/ValidateEmail.ts | 28 +++++++++
.../Core/Api/Users/ValidateUsername.ts | 28 +++++++++
.../Core/Controller/User/Registration.ts | 24 ++-----
.../Core/Api/Users/ValidateEmail.js | 19 ++++++
.../Core/Api/Users/ValidateUsername.js | 19 ++++++
.../Core/Controller/User/Registration.js | 14 +----
.../files/lib/action/ApiAction.class.php | 9 +++
.../action/EmailValidationAction.class.php | 62 -------------------
.../action/UsernameValidationAction.class.php | 62 -------------------
.../files/lib/form/RegisterForm.class.php | 10 ---
.../middleware/CheckForForceLogin.class.php | 28 ++++++---
.../AvailableDuringForceLogin.class.php | 16 +++++
.../core/users/ValidateEmail.class.php | 59 ++++++++++++++++++
.../core/users/ValidateUsername.class.php | 59 ++++++++++++++++++
15 files changed, 264 insertions(+), 175 deletions(-)
create mode 100644 ts/WoltLabSuite/Core/Api/Users/ValidateEmail.ts
create mode 100644 ts/WoltLabSuite/Core/Api/Users/ValidateUsername.ts
create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Api/Users/ValidateEmail.js
create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Api/Users/ValidateUsername.js
delete mode 100644 wcfsetup/install/files/lib/action/EmailValidationAction.class.php
delete mode 100644 wcfsetup/install/files/lib/action/UsernameValidationAction.class.php
create mode 100644 wcfsetup/install/files/lib/system/endpoint/AvailableDuringForceLogin.class.php
create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/users/ValidateEmail.class.php
create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/users/ValidateUsername.class.php
diff --git a/com.woltlab.wcf/fileDelete.xml b/com.woltlab.wcf/fileDelete.xml
index f8566e7abd3..29d4afc84bc 100644
--- a/com.woltlab.wcf/fileDelete.xml
+++ b/com.woltlab.wcf/fileDelete.xml
@@ -9,6 +9,8 @@
js/WoltLabSuite/Core/Ui/User/CoverPhoto/Upload.js
lib/form/AvatarEditForm.class.php
lib/form/UserSearchForm.class.php
+ lib/action/EmailValidationAction.class.php
+ lib/action/UsernameValidationAction.class.php
lib/system/listView/user/UserSearchResultsListView.class.php
lib/system/api/cuyz/valinor/qa/PHPStan/Stubs/Psr/SimpleCache/CacheInterface.stub
lib/system/api/cuyz/valinor/src/Cache/ChainCache.php
diff --git a/ts/WoltLabSuite/Core/Api/Users/ValidateEmail.ts b/ts/WoltLabSuite/Core/Api/Users/ValidateEmail.ts
new file mode 100644
index 00000000000..70910538ea9
--- /dev/null
+++ b/ts/WoltLabSuite/Core/Api/Users/ValidateEmail.ts
@@ -0,0 +1,28 @@
+/**
+ * Validates the email address for the registration process.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.3
+ */
+
+import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend";
+import { fromInfallibleApiRequest } from "WoltLabSuite/Core/Api/Result";
+
+type Response =
+ | {
+ ok: true;
+ }
+ | {
+ ok: false;
+ error: "invalid" | "notUnique";
+ };
+
+export async function validateEmail(email: string): Promise {
+ const url = new URL(`${window.WSC_RPC_API_URL}core/users/validate-email`);
+
+ return fromInfallibleApiRequest(() => {
+ return prepareRequest(url).post({ email }).fetchAsJson();
+ });
+}
diff --git a/ts/WoltLabSuite/Core/Api/Users/ValidateUsername.ts b/ts/WoltLabSuite/Core/Api/Users/ValidateUsername.ts
new file mode 100644
index 00000000000..1af4be50af5
--- /dev/null
+++ b/ts/WoltLabSuite/Core/Api/Users/ValidateUsername.ts
@@ -0,0 +1,28 @@
+/**
+ * Validates the username for the registration process.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.3
+ */
+
+import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend";
+import { fromInfallibleApiRequest } from "WoltLabSuite/Core/Api/Result";
+
+type Response =
+ | {
+ ok: true;
+ }
+ | {
+ ok: false;
+ error: "invalid" | "notUnique";
+ };
+
+export async function validateUsername(username: string): Promise {
+ const url = new URL(`${window.WSC_RPC_API_URL}core/users/validate-username`);
+
+ return fromInfallibleApiRequest(() => {
+ return prepareRequest(url).post({ username }).fetchAsJson();
+ });
+}
diff --git a/ts/WoltLabSuite/Core/Controller/User/Registration.ts b/ts/WoltLabSuite/Core/Controller/User/Registration.ts
index 8ad0889754a..d48c017ca03 100644
--- a/ts/WoltLabSuite/Core/Controller/User/Registration.ts
+++ b/ts/WoltLabSuite/Core/Controller/User/Registration.ts
@@ -6,19 +6,11 @@
* @license GNU Lesser General Public License
*/
-import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend";
+import { validateEmail as validateEmailAddress } from "WoltLabSuite/Core/Api/Users/ValidateEmail";
+import { validateUsername as validateUsernameValue } from "WoltLabSuite/Core/Api/Users/ValidateUsername";
import { getPhrase } from "WoltLabSuite/Core/Language";
import DomUtil from "../../Dom/Util";
-type ValidationResult =
- | {
- ok: true;
- }
- | {
- ok: false;
- error: string;
- };
-
type UsernameOptions = {
minlength: number;
maxlength: number;
@@ -37,11 +29,7 @@ async function validateUsername(username: HTMLInputElement, options: UsernameOpt
return;
}
- const result = (await prepareRequest(username.dataset.validationEndpoint!)
- .post({
- username: value,
- })
- .fetchAsJson()) as ValidationResult;
+ const result = await validateUsernameValue(value);
if (!result.ok) {
showErrorMessage(username, `wcf.user.username.error.${result.error}`);
return;
@@ -58,11 +46,7 @@ async function validateEmail(email: HTMLInputElement): Promise {
return;
}
- const result = (await prepareRequest(email.dataset.validationEndpoint!)
- .post({
- email: value,
- })
- .fetchAsJson()) as ValidationResult;
+ const result = await validateEmailAddress(value);
if (!result.ok) {
showErrorMessage(email, `wcf.user.email.error.${result.error}`);
return;
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Users/ValidateEmail.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Users/ValidateEmail.js
new file mode 100644
index 00000000000..11e4eaf3f42
--- /dev/null
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Users/ValidateEmail.js
@@ -0,0 +1,19 @@
+/**
+ * Validates the email address for the registration process.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.3
+ */
+define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Core/Api/Result"], function (require, exports, Backend_1, Result_1) {
+ "use strict";
+ Object.defineProperty(exports, "__esModule", { value: true });
+ exports.validateEmail = validateEmail;
+ async function validateEmail(email) {
+ const url = new URL(`${window.WSC_RPC_API_URL}core/users/validate-email`);
+ return (0, Result_1.fromInfallibleApiRequest)(() => {
+ return (0, Backend_1.prepareRequest)(url).post({ email }).fetchAsJson();
+ });
+ }
+});
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Users/ValidateUsername.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Users/ValidateUsername.js
new file mode 100644
index 00000000000..743807c3e08
--- /dev/null
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Users/ValidateUsername.js
@@ -0,0 +1,19 @@
+/**
+ * Validates the username for the registration process.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.3
+ */
+define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Core/Api/Result"], function (require, exports, Backend_1, Result_1) {
+ "use strict";
+ Object.defineProperty(exports, "__esModule", { value: true });
+ exports.validateUsername = validateUsername;
+ async function validateUsername(username) {
+ const url = new URL(`${window.WSC_RPC_API_URL}core/users/validate-username`);
+ return (0, Result_1.fromInfallibleApiRequest)(() => {
+ return (0, Backend_1.prepareRequest)(url).post({ username }).fetchAsJson();
+ });
+ }
+});
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Controller/User/Registration.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Controller/User/Registration.js
index d45c6052a01..e13e199cbf6 100644
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Controller/User/Registration.js
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Controller/User/Registration.js
@@ -5,7 +5,7 @@
* @copyright 2001-2023 WoltLab GmbH
* @license GNU Lesser General Public License
*/
-define(["require", "exports", "tslib", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Core/Language", "../../Dom/Util"], function (require, exports, tslib_1, Backend_1, Language_1, Util_1) {
+define(["require", "exports", "tslib", "WoltLabSuite/Core/Api/Users/ValidateEmail", "WoltLabSuite/Core/Api/Users/ValidateUsername", "WoltLabSuite/Core/Language", "../../Dom/Util"], function (require, exports, tslib_1, ValidateEmail_1, ValidateUsername_1, Language_1, Util_1) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.setup = setup;
@@ -20,11 +20,7 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Ajax/Backend", "WoltLa
showErrorMessage(username, "wcf.user.username.error.invalid");
return;
}
- const result = (await (0, Backend_1.prepareRequest)(username.dataset.validationEndpoint)
- .post({
- username: value,
- })
- .fetchAsJson());
+ const result = await (0, ValidateUsername_1.validateUsername)(value);
if (!result.ok) {
showErrorMessage(username, `wcf.user.username.error.${result.error}`);
return;
@@ -37,11 +33,7 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Ajax/Backend", "WoltLa
showErrorMessage(email, "wcf.global.form.error.empty");
return;
}
- const result = (await (0, Backend_1.prepareRequest)(email.dataset.validationEndpoint)
- .post({
- email: value,
- })
- .fetchAsJson());
+ const result = await (0, ValidateEmail_1.validateEmail)(value);
if (!result.ok) {
showErrorMessage(email, `wcf.user.email.error.${result.error}`);
return;
diff --git a/wcfsetup/install/files/lib/action/ApiAction.class.php b/wcfsetup/install/files/lib/action/ApiAction.class.php
index aafd689d223..c9b1eac0d87 100644
--- a/wcfsetup/install/files/lib/action/ApiAction.class.php
+++ b/wcfsetup/install/files/lib/action/ApiAction.class.php
@@ -13,7 +13,9 @@
use Psr\Http\Server\RequestHandlerInterface;
use wcf\event\endpoint\ControllerCollecting;
use wcf\http\attribute\AllowHttpMethod;
+use wcf\http\middleware\CheckForForceLogin;
use wcf\system\cache\builder\ApiEndpointCacheBuilder;
+use wcf\system\endpoint\AvailableDuringForceLogin;
use wcf\system\endpoint\IController;
use wcf\system\endpoint\RequestFailure;
use wcf\system\endpoint\RequestType;
@@ -106,6 +108,13 @@ function (ConfigureRoutes $r) {
}
\assert($controller instanceof IController);
+ if (
+ CheckForForceLogin::appliesToActiveUser()
+ && (new \ReflectionClass($controller))->getAttributes(AvailableDuringForceLogin::class) === []
+ ) {
+ return $this->toErrorResponse(RequestFailure::PermissionDenied, 'force_login');
+ }
+
try {
return $controller($request, $result->variables);
} catch (MappingError $e) {
diff --git a/wcfsetup/install/files/lib/action/EmailValidationAction.class.php b/wcfsetup/install/files/lib/action/EmailValidationAction.class.php
deleted file mode 100644
index 57e9e6523d6..00000000000
--- a/wcfsetup/install/files/lib/action/EmailValidationAction.class.php
+++ /dev/null
@@ -1,62 +0,0 @@
-
- * @since 6.1
- */
-final class EmailValidationAction implements RequestHandlerInterface
-{
- #[\Override]
- public function handle(ServerRequestInterface $request): ResponseInterface
- {
- if ($request->getMethod() === 'GET') {
- return new TextResponse('Unsupported', 400);
- } elseif ($request->getMethod() === 'POST') {
- $bodyParameters = Helper::mapRequestBody(
- $request->getParsedBody(),
- <<<'EOT'
- array {
- email: non-empty-string
- }
- EOT
- );
-
- $result = [
- 'ok' => true,
- ];
-
- if (!UserRegistrationUtil::isValidEmail($bodyParameters['email'])) {
- $result = [
- 'ok' => false,
- 'error' => 'invalid',
- ];
- }
-
- if (!User::getUserByEmail($bodyParameters['email'])->isNil()) {
- $result = [
- 'ok' => false,
- 'error' => 'notUnique',
- ];
- }
-
- return new JsonResponse($result);
- } else {
- throw new \LogicException('Unreachable');
- }
- }
-}
diff --git a/wcfsetup/install/files/lib/action/UsernameValidationAction.class.php b/wcfsetup/install/files/lib/action/UsernameValidationAction.class.php
deleted file mode 100644
index d072e7ca607..00000000000
--- a/wcfsetup/install/files/lib/action/UsernameValidationAction.class.php
+++ /dev/null
@@ -1,62 +0,0 @@
-
- * @since 6.1
- */
-final class UsernameValidationAction implements RequestHandlerInterface
-{
- #[\Override]
- public function handle(ServerRequestInterface $request): ResponseInterface
- {
- if ($request->getMethod() === 'GET') {
- return new TextResponse('Unsupported', 400);
- } elseif ($request->getMethod() === 'POST') {
- $bodyParameters = Helper::mapRequestBody(
- $request->getParsedBody(),
- <<<'EOT'
- array {
- username: non-empty-string
- }
- EOT
- );
-
- $result = [
- 'ok' => true,
- ];
-
- if (!UserRegistrationUtil::isValidUsername($bodyParameters['username'])) {
- $result = [
- 'ok' => false,
- 'error' => 'invalid',
- ];
- }
-
- if (!User::getUserByUsername($bodyParameters['username'])->isNil()) {
- $result = [
- 'ok' => false,
- 'error' => 'notUnique',
- ];
- }
-
- return new JsonResponse($result);
- } else {
- throw new \LogicException('Unreachable');
- }
- }
-}
diff --git a/wcfsetup/install/files/lib/form/RegisterForm.class.php b/wcfsetup/install/files/lib/form/RegisterForm.class.php
index 0efee8c4a4d..e6f019f9fdc 100644
--- a/wcfsetup/install/files/lib/form/RegisterForm.class.php
+++ b/wcfsetup/install/files/lib/form/RegisterForm.class.php
@@ -3,8 +3,6 @@
namespace wcf\form;
use ParagonIE\ConstantTime\Hex;
-use wcf\action\EmailValidationAction;
-use wcf\action\UsernameValidationAction;
use wcf\command\user\CreateRegistrationNotification;
use wcf\command\user\CreateUser;
use wcf\data\DatabaseObjectBuilder;
@@ -248,10 +246,6 @@ private function getGeneralFormContainer(): FormContainer
->minimumLength(\REGISTER_USERNAME_MIN_LENGTH)
->maximumLength(\REGISTER_USERNAME_MAX_LENGTH)
->autoComplete('username')
- ->fieldAttribute(
- 'data-validation-endpoint',
- LinkHandler::getInstance()->getControllerLink(UsernameValidationAction::class)
- )
->description('wcf.user.username.description')
->value(WCF::getSession()->getVar('__username') ?? '')
->addValidator(new FormFieldValidator(
@@ -272,10 +266,6 @@ static function (UsernameFormField $formField) {
->label('wcf.user.email')
->required()
->autoComplete('email')
- ->fieldAttribute(
- 'data-validation-endpoint',
- LinkHandler::getInstance()->getControllerLink(EmailValidationAction::class)
- )
->value(WCF::getSession()->getVar('__email') ?? '')
->addValidator(new FormFieldValidator(
'registrationUtil',
diff --git a/wcfsetup/install/files/lib/http/middleware/CheckForForceLogin.class.php b/wcfsetup/install/files/lib/http/middleware/CheckForForceLogin.class.php
index c3e7660cea4..4c74894f2e9 100644
--- a/wcfsetup/install/files/lib/http/middleware/CheckForForceLogin.class.php
+++ b/wcfsetup/install/files/lib/http/middleware/CheckForForceLogin.class.php
@@ -31,11 +31,11 @@ final class CheckForForceLogin implements MiddlewareInterface
private const STATUS_CODE = 403;
private const ALLOWED_CONTROLLERS = [
+ // The API enforces the 'force login' option for each endpoint, see `AvailableDuringForceLogin`.
+ \wcf\action\ApiAction::class,
\wcf\action\BackgroundQueuePerformAction::class,
\wcf\action\CronjobPerformAction::class,
- \wcf\action\EmailValidationAction::class,
\wcf\action\PaypalCallbackAction::class,
- \wcf\action\UsernameValidationAction::class,
\wcf\form\EmailActivationForm::class,
\wcf\form\EmailNewActivationCodeForm::class,
\wcf\form\LoginForm::class,
@@ -50,12 +50,7 @@ final class CheckForForceLogin implements MiddlewareInterface
#[\Override]
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
- if (
- !$this->forceLoginEnabled()
- || RequestHandler::getInstance()->isACPRequest()
- || $this->userCanBypassForceLogin()
- || $this->requestCanBypassForceLogin()
- ) {
+ if (!self::appliesToActiveUser() || $this->requestCanBypassForceLogin()) {
return $handler->handle($request);
}
@@ -133,12 +128,25 @@ private function handleGuest(ServerRequestInterface $request): ResponseInterface
};
}
- private function forceLoginEnabled(): bool
+ /**
+ * Returns true if the 'force login' option restricts the requests of the
+ * active user, regardless of the requested controller.
+ *
+ * @since 6.3
+ */
+ public static function appliesToActiveUser(): bool
+ {
+ return self::forceLoginEnabled()
+ && !RequestHandler::getInstance()->isACPRequest()
+ && !self::userCanBypassForceLogin();
+ }
+
+ private static function forceLoginEnabled(): bool
{
return \defined('FORCE_LOGIN') && \FORCE_LOGIN !== 0;
}
- private function userCanBypassForceLogin(): bool
+ private static function userCanBypassForceLogin(): bool
{
return !WCF::getUser()->isGuest()
&& !WCF::getUser()->pendingActivation();
diff --git a/wcfsetup/install/files/lib/system/endpoint/AvailableDuringForceLogin.class.php b/wcfsetup/install/files/lib/system/endpoint/AvailableDuringForceLogin.class.php
new file mode 100644
index 00000000000..12aa0e7bded
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/endpoint/AvailableDuringForceLogin.class.php
@@ -0,0 +1,16 @@
+
+ * @since 6.3
+ */
+#[\Attribute(\Attribute::TARGET_CLASS)]
+final class AvailableDuringForceLogin {}
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/ValidateEmail.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/ValidateEmail.class.php
new file mode 100644
index 00000000000..f9b3d50ae86
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/ValidateEmail.class.php
@@ -0,0 +1,59 @@
+
+ * @since 6.3
+ */
+#[PostRequest('/core/users/validate-email')]
+#[AvailableDuringForceLogin]
+final class ValidateEmail implements IController
+{
+ #[\Override]
+ public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface
+ {
+ $parameters = Helper::mapApiParameters($request, ValidateEmailParameters::class);
+
+ if (!User::getUserByEmail($parameters->email)->isNil()) {
+ return new JsonResponse([
+ 'ok' => false,
+ 'error' => 'notUnique',
+ ]);
+ }
+
+ if (!UserRegistrationUtil::isValidEmail($parameters->email)) {
+ return new JsonResponse([
+ 'ok' => false,
+ 'error' => 'invalid',
+ ]);
+ }
+
+ return new JsonResponse([
+ 'ok' => true,
+ ]);
+ }
+}
+
+/** @internal */
+final class ValidateEmailParameters
+{
+ public function __construct(
+ /** @var non-empty-string */
+ public readonly string $email,
+ ) {}
+}
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/ValidateUsername.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/ValidateUsername.class.php
new file mode 100644
index 00000000000..fb6b50d1b0d
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/ValidateUsername.class.php
@@ -0,0 +1,59 @@
+
+ * @since 6.3
+ */
+#[PostRequest('/core/users/validate-username')]
+#[AvailableDuringForceLogin]
+final class ValidateUsername implements IController
+{
+ #[\Override]
+ public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface
+ {
+ $parameters = Helper::mapApiParameters($request, ValidateUsernameParameters::class);
+
+ if (!User::getUserByUsername($parameters->username)->isNil()) {
+ return new JsonResponse([
+ 'ok' => false,
+ 'error' => 'notUnique',
+ ]);
+ }
+
+ if (!UserRegistrationUtil::isValidUsername($parameters->username)) {
+ return new JsonResponse([
+ 'ok' => false,
+ 'error' => 'invalid',
+ ]);
+ }
+
+ return new JsonResponse([
+ 'ok' => true,
+ ]);
+ }
+}
+
+/** @internal */
+final class ValidateUsernameParameters
+{
+ public function __construct(
+ /** @var non-empty-string */
+ public readonly string $username,
+ ) {}
+}
From 5e74aade09376151164f72bb8172b2baf88db600 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Sun, 4 Oct 2026 21:51:58 +0200
Subject: [PATCH 016/115] Refactor `UserListPage` into a grid view
---
com.woltlab.wcf/acpTemplateDelete.xml | 2 +
com.woltlab.wcf/clipboardAction.xml | 58 +--
com.woltlab.wcf/fileDelete.xml | 17 +
phpstan-baseline.neon | 14 +-
.../Core/Acp/Component/User/RemoveContent.ts | 92 ++++
.../Core/Acp/Controller/User/List.ts | 100 ----
.../Core/Acp/Ui/User/Action/Abstract.ts | 22 -
.../Core/Acp/Ui/User/Action/BanAction.ts | 56 ---
.../Core/Acp/Ui/User/Action/DeleteAction.ts | 36 --
.../Core/Acp/Ui/User/Action/DisableAction.ts | 68 ---
.../Core/Acp/Ui/User/Action/Handler/Ban.ts | 42 --
.../Acp/Ui/User/Action/Handler/Ban/Dialog.ts | 151 ------
.../Core/Acp/Ui/User/Action/Handler/Delete.ts | 43 --
.../Ui/User/Action/Handler/SendNewPassword.ts | 1 +
.../Ui/User/Action/SendNewPasswordAction.ts | 28 --
.../User/Action/ToggleConfirmEmailAction.ts | 63 ---
.../Acp/Ui/User/Content/Remove/Clipboard.ts | 125 -----
.../Acp/Ui/User/Content/Remove/Handler.ts | 130 ------
ts/WoltLabSuite/Core/Acp/Ui/User/Editor.ts | 183 --------
.../acp/templates/removeUserContentDialog.tpl | 23 -
.../install/files/acp/templates/userAdd.tpl | 147 +-----
.../files/acp/templates/userAssignToGroup.tpl | 1 +
.../templates/userContentRevertChanges.tpl | 1 +
.../acp/templates/userEmailAddressExport.tpl | 1 +
.../install/files/acp/templates/userList.tpl | 256 +----------
.../install/files/acp/templates/userMerge.tpl | 1 +
.../files/acp/templates/userSearch.tpl | 134 ------
.../usersAwaitingApprovalAcpDashboardBox.tpl | 2 +-
.../Core/Acp/Component/User/RemoveContent.js | 74 +++
.../Core/Acp/Controller/User/List.js | 80 ----
.../Core/Acp/Ui/User/Action/Abstract.js | 25 -
.../Core/Acp/Ui/User/Action/BanAction.js | 50 --
.../Core/Acp/Ui/User/Action/DeleteAction.js | 32 --
.../Core/Acp/Ui/User/Action/DisableAction.js | 61 ---
.../Core/Acp/Ui/User/Action/Handler/Ban.js | 40 --
.../Acp/Ui/User/Action/Handler/Ban/Dialog.js | 135 ------
.../Core/Acp/Ui/User/Action/Handler/Delete.js | 43 --
.../Ui/User/Action/Handler/SendNewPassword.js | 1 +
.../Ui/User/Action/SendNewPasswordAction.js | 29 --
.../User/Action/ToggleConfirmEmailAction.js | 57 ---
.../Acp/Ui/User/Content/Remove/Clipboard.js | 92 ----
.../Acp/Ui/User/Content/Remove/Handler.js | 95 ----
.../WoltLabSuite/Core/Acp/Ui/User/Editor.js | 157 -------
.../lib/acp/action/UserBanAction.class.php | 140 ++++++
.../action/UserContentRemoveAction.class.php | 132 ++++++
.../action/UserQuickSearchAction.class.php | 206 ---------
.../lib/acp/form/TMultipleUsersForm.class.php | 47 ++
.../acp/form/UserAssignToGroupForm.class.php | 28 +-
.../UserContentRevertChangesForm.class.php | 25 +-
.../files/lib/acp/form/UserEditForm.class.php | 11 +-
.../form/UserEmailAddressExportForm.class.php | 32 +-
.../files/lib/acp/form/UserMailForm.class.php | 11 +-
.../lib/acp/form/UserMergeForm.class.php | 19 +-
.../lib/acp/form/UserSearchForm.class.php | 305 -------------
.../files/lib/acp/page/UserListPage.class.php | 432 +-----------------
.../files/lib/command/user/BanUser.class.php | 34 ++
.../command/user/ConfirmUserEmail.class.php | 39 ++
.../lib/command/user/DisableUser.class.php | 55 +++
.../lib/command/user/EnableUser.class.php | 82 ++++
.../user/ResendActivationMail.class.php | 45 ++
.../command/user/SendNewPassword.class.php | 52 +++
.../lib/command/user/UnbanUser.class.php | 30 ++
.../command/user/UnconfirmUserEmail.class.php | 30 ++
.../files/lib/data/user/UserAction.class.php | 237 ++--------
.../files/lib/data/user/UserBuilder.class.php | 18 +
.../admin/UserGridViewInitialized.class.php | 19 +
.../admin/UserInteractionCollecting.class.php | 19 +
.../UserBulkInteractionCollecting.class.php | 19 +
.../action/UserClipboardAction.class.php | 375 ---------------
.../UserContentClipboardAction.class.php | 81 ----
.../core/users/ConfirmUserEmail.class.php | 38 ++
.../core/users/DeleteUser.class.php | 39 ++
.../core/users/DisableUser.class.php | 38 ++
.../core/users/EnableUser.class.php | 38 ++
.../core/users/ResendActivationMail.class.php | 36 ++
.../core/users/SendNewPassword.class.php | 36 ++
.../controller/core/users/UnbanUser.class.php | 38 ++
.../core/users/UnconfirmUserEmail.class.php | 38 ++
.../AcpMenuItemCollectingListener.class.php | 6 -
.../gridView/admin/UserGridView.class.php | 399 ++++++++++++++++
.../admin/UserGroupGridView.class.php | 15 +-
.../UserContentRemoveInteraction.class.php | 46 ++
.../admin/UserInteractions.class.php | 136 ++++++
.../bulk/BulkLinkInteraction.class.php | 50 ++
.../bulk/admin/UserBulkInteractions.class.php | 138 ++++++
...UserContentRemoveBulkInteraction.class.php | 45 ++
.../listView/user/MembersListView.class.php | 17 +-
.../user/UserAdministrationPolicy.class.php | 144 ++++++
.../view/filter/UserAgeFilter.class.php | 11 +-
.../view/filter/UserOptionFilter.class.php | 27 ++
.../worker/SendNewPasswordWorker.class.php | 82 +---
wcfsetup/install/lang/de.xml | 78 ++--
wcfsetup/install/lang/en.xml | 78 ++--
93 files changed, 2490 insertions(+), 4374 deletions(-)
create mode 100644 ts/WoltLabSuite/Core/Acp/Component/User/RemoveContent.ts
delete mode 100644 ts/WoltLabSuite/Core/Acp/Controller/User/List.ts
delete mode 100644 ts/WoltLabSuite/Core/Acp/Ui/User/Action/Abstract.ts
delete mode 100644 ts/WoltLabSuite/Core/Acp/Ui/User/Action/BanAction.ts
delete mode 100644 ts/WoltLabSuite/Core/Acp/Ui/User/Action/DeleteAction.ts
delete mode 100644 ts/WoltLabSuite/Core/Acp/Ui/User/Action/DisableAction.ts
delete mode 100644 ts/WoltLabSuite/Core/Acp/Ui/User/Action/Handler/Ban.ts
delete mode 100644 ts/WoltLabSuite/Core/Acp/Ui/User/Action/Handler/Ban/Dialog.ts
delete mode 100644 ts/WoltLabSuite/Core/Acp/Ui/User/Action/Handler/Delete.ts
delete mode 100644 ts/WoltLabSuite/Core/Acp/Ui/User/Action/SendNewPasswordAction.ts
delete mode 100644 ts/WoltLabSuite/Core/Acp/Ui/User/Action/ToggleConfirmEmailAction.ts
delete mode 100644 ts/WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Clipboard.ts
delete mode 100644 ts/WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Handler.ts
delete mode 100644 ts/WoltLabSuite/Core/Acp/Ui/User/Editor.ts
delete mode 100644 wcfsetup/install/files/acp/templates/removeUserContentDialog.tpl
delete mode 100644 wcfsetup/install/files/acp/templates/userSearch.tpl
create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Component/User/RemoveContent.js
delete mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Controller/User/List.js
delete mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/Abstract.js
delete mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/BanAction.js
delete mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/DeleteAction.js
delete mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/DisableAction.js
delete mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/Handler/Ban.js
delete mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/Handler/Ban/Dialog.js
delete mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/Handler/Delete.js
delete mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/SendNewPasswordAction.js
delete mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/ToggleConfirmEmailAction.js
delete mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Clipboard.js
delete mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Handler.js
delete mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Editor.js
create mode 100644 wcfsetup/install/files/lib/acp/action/UserBanAction.class.php
create mode 100644 wcfsetup/install/files/lib/acp/action/UserContentRemoveAction.class.php
delete mode 100644 wcfsetup/install/files/lib/acp/action/UserQuickSearchAction.class.php
create mode 100644 wcfsetup/install/files/lib/acp/form/TMultipleUsersForm.class.php
delete mode 100755 wcfsetup/install/files/lib/acp/form/UserSearchForm.class.php
create mode 100644 wcfsetup/install/files/lib/command/user/BanUser.class.php
create mode 100644 wcfsetup/install/files/lib/command/user/ConfirmUserEmail.class.php
create mode 100644 wcfsetup/install/files/lib/command/user/DisableUser.class.php
create mode 100644 wcfsetup/install/files/lib/command/user/EnableUser.class.php
create mode 100644 wcfsetup/install/files/lib/command/user/ResendActivationMail.class.php
create mode 100644 wcfsetup/install/files/lib/command/user/SendNewPassword.class.php
create mode 100644 wcfsetup/install/files/lib/command/user/UnbanUser.class.php
create mode 100644 wcfsetup/install/files/lib/command/user/UnconfirmUserEmail.class.php
create mode 100644 wcfsetup/install/files/lib/event/gridView/admin/UserGridViewInitialized.class.php
create mode 100644 wcfsetup/install/files/lib/event/interaction/admin/UserInteractionCollecting.class.php
create mode 100644 wcfsetup/install/files/lib/event/interaction/bulk/admin/UserBulkInteractionCollecting.class.php
delete mode 100644 wcfsetup/install/files/lib/system/clipboard/action/UserClipboardAction.class.php
delete mode 100644 wcfsetup/install/files/lib/system/clipboard/action/UserContentClipboardAction.class.php
create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/users/ConfirmUserEmail.class.php
create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/users/DeleteUser.class.php
create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/users/DisableUser.class.php
create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/users/EnableUser.class.php
create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/users/ResendActivationMail.class.php
create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/users/SendNewPassword.class.php
create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/users/UnbanUser.class.php
create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/users/UnconfirmUserEmail.class.php
create mode 100644 wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php
create mode 100644 wcfsetup/install/files/lib/system/interaction/admin/UserContentRemoveInteraction.class.php
create mode 100644 wcfsetup/install/files/lib/system/interaction/admin/UserInteractions.class.php
create mode 100644 wcfsetup/install/files/lib/system/interaction/bulk/BulkLinkInteraction.class.php
create mode 100644 wcfsetup/install/files/lib/system/interaction/bulk/admin/UserBulkInteractions.class.php
create mode 100644 wcfsetup/install/files/lib/system/interaction/bulk/admin/UserContentRemoveBulkInteraction.class.php
create mode 100644 wcfsetup/install/files/lib/system/user/UserAdministrationPolicy.class.php
diff --git a/com.woltlab.wcf/acpTemplateDelete.xml b/com.woltlab.wcf/acpTemplateDelete.xml
index d147759f63b..d54ca8d0361 100644
--- a/com.woltlab.wcf/acpTemplateDelete.xml
+++ b/com.woltlab.wcf/acpTemplateDelete.xml
@@ -157,5 +157,7 @@
imageViewer
tagSetAsSynonyms
contactSettings
+ removeUserContentDialog
+ userSearch
diff --git a/com.woltlab.wcf/clipboardAction.xml b/com.woltlab.wcf/clipboardAction.xml
index 66c6f2ebf5e..21ffa86a7bf 100644
--- a/com.woltlab.wcf/clipboardAction.xml
+++ b/com.woltlab.wcf/clipboardAction.xml
@@ -1,6 +1,35 @@
+
+
+ wcf\system\clipboard\action\MediaClipboardAction
+ 1
+
+ menuManagerDialog-editor
+
+
+
+ wcf\system\clipboard\action\MediaClipboardAction
+ 2
+
+ wcf\acp\page\MediaListPage
+ menuManagerDialog-editor
+ menuManagerDialog-select
+
+
+
+ wcf\system\clipboard\action\MediaClipboardAction
+ 3
+
+ wcf\acp\page\MediaListPage
+ menuManagerDialog-editor
+ menuManagerDialog-select
+
+
+
+
+
wcf\system\clipboard\action\UserClipboardAction
@@ -94,35 +123,6 @@
-
-
- wcf\system\clipboard\action\MediaClipboardAction
- 1
-
- menuManagerDialog-editor
-
-
-
- wcf\system\clipboard\action\MediaClipboardAction
- 2
-
- wcf\acp\page\MediaListPage
- menuManagerDialog-editor
- menuManagerDialog-select
-
-
-
- wcf\system\clipboard\action\MediaClipboardAction
- 3
-
- wcf\acp\page\MediaListPage
- menuManagerDialog-editor
- menuManagerDialog-select
-
-
-
-
-
wcf\system\clipboard\action\ArticleClipboardAction
diff --git a/com.woltlab.wcf/fileDelete.xml b/com.woltlab.wcf/fileDelete.xml
index 29d4afc84bc..8d48270497e 100644
--- a/com.woltlab.wcf/fileDelete.xml
+++ b/com.woltlab.wcf/fileDelete.xml
@@ -11,6 +11,23 @@
lib/form/UserSearchForm.class.php
lib/action/EmailValidationAction.class.php
lib/action/UsernameValidationAction.class.php
+ js/WoltLabSuite/Core/Acp/Controller/User/List.js
+ js/WoltLabSuite/Core/Acp/Ui/User/Editor.js
+ js/WoltLabSuite/Core/Acp/Ui/User/Action/Abstract.js
+ js/WoltLabSuite/Core/Acp/Ui/User/Action/BanAction.js
+ js/WoltLabSuite/Core/Acp/Ui/User/Action/DeleteAction.js
+ js/WoltLabSuite/Core/Acp/Ui/User/Action/DisableAction.js
+ js/WoltLabSuite/Core/Acp/Ui/User/Action/SendNewPasswordAction.js
+ js/WoltLabSuite/Core/Acp/Ui/User/Action/ToggleConfirmEmailAction.js
+ js/WoltLabSuite/Core/Acp/Ui/User/Action/Handler/Ban.js
+ js/WoltLabSuite/Core/Acp/Ui/User/Action/Handler/Ban/Dialog.js
+ js/WoltLabSuite/Core/Acp/Ui/User/Action/Handler/Delete.js
+ js/WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Clipboard.js
+ js/WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Handler.js
+ lib/acp/action/UserQuickSearchAction.class.php
+ lib/acp/form/UserSearchForm.class.php
+ lib/system/clipboard/action/UserClipboardAction.class.php
+ lib/system/clipboard/action/UserContentClipboardAction.class.php
lib/system/listView/user/UserSearchResultsListView.class.php
lib/system/api/cuyz/valinor/qa/PHPStan/Stubs/Psr/SimpleCache/CacheInterface.stub
lib/system/api/cuyz/valinor/src/Cache/ChainCache.php
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
index 23e6e51601b..3fef83c12c4 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -253,12 +253,6 @@ parameters:
count: 1
path: wcfsetup/install/files/lib/acp/page/TemplateDiffPage.class.php
- -
- message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
- identifier: empty.notAllowed
- count: 1
- path: wcfsetup/install/files/lib/acp/page/UserListPage.class.php
-
-
message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
identifier: empty.notAllowed
@@ -646,7 +640,7 @@ parameters:
-
message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
identifier: empty.notAllowed
- count: 10
+ count: 9
path: wcfsetup/install/files/lib/data/user/UserAction.class.php
-
@@ -1183,12 +1177,6 @@ parameters:
count: 1
path: wcfsetup/install/files/lib/system/clipboard/action/AbstractClipboardAction.class.php
- -
- message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
- identifier: empty.notAllowed
- count: 1
- path: wcfsetup/install/files/lib/system/clipboard/action/UserClipboardAction.class.php
-
-
message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
identifier: empty.notAllowed
diff --git a/ts/WoltLabSuite/Core/Acp/Component/User/RemoveContent.ts b/ts/WoltLabSuite/Core/Acp/Component/User/RemoveContent.ts
new file mode 100644
index 00000000000..1f3c528014c
--- /dev/null
+++ b/ts/WoltLabSuite/Core/Acp/Component/User/RemoveContent.ts
@@ -0,0 +1,92 @@
+/**
+ * Selects the content of one or more users in a dialog and removes it with
+ * the `UserContentRemoveWorker`.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.3
+ */
+
+import AcpUiWorker from "WoltLabSuite/Core/Acp/Ui/Worker";
+import { dialogFactory } from "WoltLabSuite/Core/Component/Dialog";
+import { showDefaultSuccessSnackbar } from "WoltLabSuite/Core/Component/Snackbar";
+import { getPhrase } from "WoltLabSuite/Core/Language";
+
+type Result = {
+ userIDs: number[];
+ contentProviders: string[];
+};
+
+async function removeContent(endpoint: string): Promise {
+ const { ok, result } = await dialogFactory().usingFormBuilder().fromEndpoint(endpoint);
+ if (!ok) {
+ return false;
+ }
+
+ return new Promise((resolve) => {
+ new AcpUiWorker({
+ dialogId: "removeContentWorker",
+ dialogTitle: getPhrase("wcf.acp.content.removeContent"),
+ className: "wcf\\system\\worker\\UserContentRemoveWorker",
+ parameters: {
+ userIDs: result.userIDs,
+ contentProvider: result.contentProviders,
+ },
+ callbackAbort: () => resolve(false),
+ callbackSuccess: () => resolve(true),
+ });
+ });
+}
+
+export function setup(identifier: string, container: HTMLElement): void {
+ container.addEventListener("interaction:execute", (event: CustomEvent>) => {
+ if (event.detail.interaction !== identifier) {
+ return;
+ }
+
+ const element = event.target as HTMLElement;
+ void removeContent(event.detail.endpoint).then((ok) => {
+ if (!ok) {
+ return;
+ }
+
+ // The activity points and the number of likes received may have changed.
+ element.dispatchEvent(
+ new CustomEvent("interaction:invalidate", {
+ bubbles: true,
+ detail: event.detail,
+ }),
+ );
+
+ showDefaultSuccessSnackbar();
+ });
+ });
+}
+
+export function setupBulk(identifier: string, container: HTMLElement): void {
+ container.addEventListener("bulk-interaction", (event: CustomEvent>) => {
+ if (event.detail.bulkInteraction !== identifier) {
+ return;
+ }
+
+ const objectIds = JSON.parse(event.detail.objectIds) as number[];
+ void removeContent(event.detail.endpoint).then((ok) => {
+ if (!ok) {
+ return;
+ }
+
+ for (const objectId of objectIds) {
+ container.querySelector(`[data-object-id="${objectId}"]`)?.dispatchEvent(
+ new CustomEvent("interaction:invalidate", {
+ bubbles: true,
+ }),
+ );
+ }
+
+ showDefaultSuccessSnackbar();
+
+ container.dispatchEvent(new CustomEvent("interaction:reset-selection"));
+ });
+ });
+}
diff --git a/ts/WoltLabSuite/Core/Acp/Controller/User/List.ts b/ts/WoltLabSuite/Core/Acp/Controller/User/List.ts
deleted file mode 100644
index c8cd6504a55..00000000000
--- a/ts/WoltLabSuite/Core/Acp/Controller/User/List.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-/**
- * Handles the list of users.
- *
- * @author Olaf Braun
- * @copyright 2001-2024 WoltLab GmbH
- * @license GNU Lesser General Public License
- */
-
-import * as EventHandler from "../../../Event/Handler";
-import { ClipboardActionData } from "WoltLabSuite/Core/Controller/Clipboard/Data";
-import { dboAction } from "WoltLabSuite/Core/Ajax";
-import UiDropdownSimple from "WoltLabSuite/Core/Ui/Dropdown/Simple";
-import BanHandler from "WoltLabSuite/Core/Acp/Ui/User/Action/Handler/Ban";
-import SendNewPassword from "WoltLabSuite/Core/Acp/Ui/User/Action/Handler/SendNewPassword";
-import { setup as setupClipboard, unmark as unmarkClipboard } from "WoltLabSuite/Core/Controller/Clipboard";
-import AcpUiUserList from "WoltLabSuite/Core/Acp/Ui/User/Editor";
-import { AcpUserContentRemoveClipboard } from "WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Clipboard";
-import { showDefaultSuccessSnackbar } from "WoltLabSuite/Core/Component/Snackbar";
-
-function getUserElements(userIDs: number[]): HTMLElement[] {
- return Array.from(document.querySelectorAll(".jsUserRow")).filter((userRow) =>
- userIDs.includes(parseInt(userRow.dataset.objectId!)),
- );
-}
-
-function getDropdownMenu(userRow: HTMLElement): HTMLElement {
- const userId = ~~userRow.dataset.objectId!;
- const dropdownId = `userListDropdown${userId}`;
- return UiDropdownSimple.getDropdownMenu(dropdownId)!;
-}
-
-function refresh(userIDs: number[]) {
- unmarkClipboard("com.woltlab.wcf.user", userIDs);
-
- showDefaultSuccessSnackbar();
-
- EventHandler.fire("com.woltlab.wcf.acp.user", "refresh", {
- userIds: userIDs,
- });
-}
-
-async function enableUsers(userIDs: number[]) {
- await dboAction("enable", "wcf\\data\\user\\UserAction").objectIds(userIDs).dispatch();
-
- getUserElements(userIDs).forEach((userRow) => {
- userRow.dataset.enabled = "true";
- const button = getDropdownMenu(userRow).querySelector(".jsEnable")!;
- button.textContent = button.dataset.disableMessage!;
- });
-
- refresh(userIDs);
-}
-
-function banUsers(userIDs: number[]) {
- new BanHandler(userIDs).ban(() => {
- getUserElements(userIDs).forEach((userRow) => {
- userRow.dataset.banned = "true";
-
- const button = getDropdownMenu(userRow).querySelector(".jsBan")!;
- button.textContent = button.dataset.unbanMessage!;
-
- refresh(userIDs);
- });
- });
-}
-
-function sendNewPasswords(userIDs: number[]) {
- new SendNewPassword(userIDs, () => {
- refresh(userIDs);
- }).send();
-}
-
-function setupUserClipboard(hasMarkedItems: boolean) {
- setupClipboard({
- pageClassName: "wcf\\acp\\page\\UserListPage",
- hasMarkedItems: hasMarkedItems,
- });
-
- EventHandler.add("com.woltlab.wcf.clipboard", "com.woltlab.wcf.user", (data: { data: ClipboardActionData }) => {
- switch (data.data.actionName) {
- case "com.woltlab.wcf.user.enable":
- void enableUsers(data.data.parameters.objectIDs);
- break;
- case "com.woltlab.wcf.user.ban":
- banUsers(data.data.parameters.objectIDs);
- break;
- case "com.woltlab.wcf.user.sendNewPassword":
- sendNewPasswords(data.data.parameters.objectIDs);
- break;
- }
- });
-
- new AcpUserContentRemoveClipboard();
-}
-
-export function setup(hasMarkedItems: boolean) {
- setupUserClipboard(hasMarkedItems);
-
- new AcpUiUserList();
-}
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/User/Action/Abstract.ts b/ts/WoltLabSuite/Core/Acp/Ui/User/Action/Abstract.ts
deleted file mode 100644
index 6cb507b310e..00000000000
--- a/ts/WoltLabSuite/Core/Acp/Ui/User/Action/Abstract.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-/**
- * An abstract action, to handle user actions.
- *
- * @author Joshua Ruesweg
- * @copyright 2001-2021 WoltLab GmbH
- * @license GNU Lesser General Public License
- * @since 5.5
- */
-
-export abstract class AbstractUserAction {
- protected readonly button: HTMLElement;
- protected readonly userDataElement: HTMLElement;
- protected readonly userId: number;
-
- public constructor(button: HTMLElement, userId: number, userDataElement: HTMLElement) {
- this.button = button;
- this.userId = userId;
- this.userDataElement = userDataElement;
- }
-}
-
-export default AbstractUserAction;
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/User/Action/BanAction.ts b/ts/WoltLabSuite/Core/Acp/Ui/User/Action/BanAction.ts
deleted file mode 100644
index d9ed4c479f5..00000000000
--- a/ts/WoltLabSuite/Core/Acp/Ui/User/Action/BanAction.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-/**
- * Handles a user ban button.
- *
- * @author Joshua Ruesweg
- * @copyright 2001-2021 WoltLab GmbH
- * @license GNU Lesser General Public License
- * @since 5.5
- */
-
-import * as Core from "../../../../Core";
-import AbstractUserAction from "./Abstract";
-import BanHandler from "./Handler/Ban";
-import * as EventHandler from "../../../../Event/Handler";
-import { showDefaultSuccessSnackbar } from "WoltLabSuite/Core/Component/Snackbar";
-
-export class BanAction extends AbstractUserAction {
- private banHandler: BanHandler;
-
- public constructor(button: HTMLElement, userId: number, userDataElement: HTMLElement) {
- super(button, userId, userDataElement);
-
- this.banHandler = new BanHandler([this.userId]);
-
- this.button.addEventListener("click", (event) => {
- event.preventDefault();
-
- const isBanned = Core.stringToBool(this.userDataElement.dataset.banned!);
-
- if (isBanned) {
- this.banHandler.unban(() => {
- this.userDataElement.dataset.banned = "false";
- this.button.textContent = this.button.dataset.banMessage!;
-
- showDefaultSuccessSnackbar();
-
- EventHandler.fire("com.woltlab.wcf.acp.user", "refresh", {
- userIds: [this.userId],
- });
- });
- } else {
- this.banHandler.ban(() => {
- this.userDataElement.dataset.banned = "true";
- this.button.textContent = this.button.dataset.unbanMessage!;
-
- showDefaultSuccessSnackbar();
-
- EventHandler.fire("com.woltlab.wcf.acp.user", "refresh", {
- userIds: [this.userId],
- });
- });
- }
- });
- }
-}
-
-export default BanAction;
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/User/Action/DeleteAction.ts b/ts/WoltLabSuite/Core/Acp/Ui/User/Action/DeleteAction.ts
deleted file mode 100644
index f75c256b500..00000000000
--- a/ts/WoltLabSuite/Core/Acp/Ui/User/Action/DeleteAction.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-/**
- * Handles a user delete button.
- *
- * @author Joshua Ruesweg
- * @copyright 2001-2021 WoltLab GmbH
- * @license GNU Lesser General Public License
- * @since 5.5
- */
-
-import AbstractUserAction from "./Abstract";
-import Delete from "./Handler/Delete";
-
-export class DeleteAction extends AbstractUserAction {
- public constructor(button: HTMLElement, userId: number, userDataElement: HTMLElement) {
- super(button, userId, userDataElement);
-
- if (typeof this.button.dataset.confirmMessage !== "string") {
- throw new Error("The button does not provide a confirmMessage.");
- }
-
- this.button.addEventListener("click", (event) => {
- event.preventDefault();
-
- const deleteHandler = new Delete(
- [this.userId],
- () => {
- this.userDataElement.remove();
- },
- this.button.dataset.confirmMessage!,
- );
- deleteHandler.delete();
- });
- }
-}
-
-export default DeleteAction;
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/User/Action/DisableAction.ts b/ts/WoltLabSuite/Core/Acp/Ui/User/Action/DisableAction.ts
deleted file mode 100644
index 146c136c86a..00000000000
--- a/ts/WoltLabSuite/Core/Acp/Ui/User/Action/DisableAction.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-/**
- * Handles a user disable/enable button.
- *
- * @author Joshua Ruesweg
- * @copyright 2001-2021 WoltLab GmbH
- * @license GNU Lesser General Public License
- * @since 5.5
- */
-
-import * as Ajax from "../../../../Ajax";
-import * as Core from "../../../../Core";
-import { AjaxCallbackObject, AjaxCallbackSetup, DatabaseObjectActionResponse } from "../../../../Ajax/Data";
-import AbstractUserAction from "./Abstract";
-import * as EventHandler from "../../../../Event/Handler";
-import { showDefaultSuccessSnackbar } from "WoltLabSuite/Core/Component/Snackbar";
-
-export class DisableAction extends AbstractUserAction implements AjaxCallbackObject {
- public constructor(button: HTMLElement, userId: number, userDataElement: HTMLElement) {
- super(button, userId, userDataElement);
-
- this.button.addEventListener("click", (event) => {
- event.preventDefault();
- const isEnabled = Core.stringToBool(this.userDataElement.dataset.enabled!);
-
- Ajax.api(this, {
- actionName: isEnabled ? "disable" : "enable",
- });
- });
- }
-
- _ajaxSetup(): ReturnType {
- return {
- data: {
- className: "wcf\\data\\user\\UserAction",
- objectIDs: [this.userId],
- },
- };
- }
-
- _ajaxSuccess(data: DatabaseObjectActionResponse): void {
- data.objectIDs.forEach((objectId) => {
- if (~~objectId == this.userId) {
- switch (data.actionName) {
- case "enable":
- this.userDataElement.dataset.enabled = "true";
- this.button.textContent = this.button.dataset.disableMessage!;
- break;
-
- case "disable":
- this.userDataElement.dataset.enabled = "false";
- this.button.textContent = this.button.dataset.enableMessage!;
- break;
-
- default:
- throw new Error("Unreachable");
- }
- }
- });
-
- showDefaultSuccessSnackbar();
-
- EventHandler.fire("com.woltlab.wcf.acp.user", "refresh", {
- userIds: [this.userId],
- });
- }
-}
-
-export default DisableAction;
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/User/Action/Handler/Ban.ts b/ts/WoltLabSuite/Core/Acp/Ui/User/Action/Handler/Ban.ts
deleted file mode 100644
index aeccca877c6..00000000000
--- a/ts/WoltLabSuite/Core/Acp/Ui/User/Action/Handler/Ban.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * Handles a user ban.
- *
- * @author Joshua Ruesweg
- * @copyright 2001-2021 WoltLab GmbH
- * @license GNU Lesser General Public License
- * @since 5.5
- */
-
-import * as Ajax from "../../../../../Ajax";
-import BanDialog from "./Ban/Dialog";
-
-type Callback = () => void;
-
-export class BanHandler {
- private userIDs: number[];
-
- public constructor(userIDs: number[]) {
- this.userIDs = userIDs;
- }
-
- public ban(callback: Callback): void {
- BanDialog.open(this.userIDs, callback);
- }
-
- public unban(callback: Callback): void {
- Ajax.api({
- _ajaxSetup: () => {
- return {
- data: {
- actionName: "unban",
- className: "wcf\\data\\user\\UserAction",
- objectIDs: this.userIDs,
- },
- };
- },
- _ajaxSuccess: () => callback(),
- });
- }
-}
-
-export default BanHandler;
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/User/Action/Handler/Ban/Dialog.ts b/ts/WoltLabSuite/Core/Acp/Ui/User/Action/Handler/Ban/Dialog.ts
deleted file mode 100644
index 10946cdc918..00000000000
--- a/ts/WoltLabSuite/Core/Acp/Ui/User/Action/Handler/Ban/Dialog.ts
+++ /dev/null
@@ -1,151 +0,0 @@
-/**
- * Creates and handles the dialog to ban a user.
- *
- * @author Joshua Ruesweg
- * @copyright 2001-2021 WoltLab GmbH
- * @license GNU Lesser General Public License
- * @since 5.5
- */
-
-import UiDialog from "../../../../../../Ui/Dialog";
-import { DialogCallbackSetup } from "../../../../../../Ui/Dialog/Data";
-import { getPhrase } from "WoltLabSuite/Core/Language";
-import * as Ajax from "../../../../../../Ajax";
-import DatePicker from "../../../../../../Date/Picker";
-
-type Callback = () => void;
-
-export class BanDialog {
- private static instance: BanDialog;
-
- private banCallback: Callback;
- private userIDs: number[];
- private submitElement: HTMLElement;
- private neverExpiresCheckbox: HTMLInputElement;
- private reasonInput: HTMLInputElement;
- private userBanExpiresSettingsElement: HTMLElement;
- private dialogContent: HTMLElement;
-
- public static open(userIDs: number[], callback: Callback): void {
- if (!BanDialog.instance) {
- BanDialog.instance = new BanDialog();
- }
-
- BanDialog.instance.setCallback(callback);
- BanDialog.instance.setUserIDs(userIDs);
- BanDialog.instance.openDialog();
- }
-
- private openDialog(): void {
- UiDialog.open(this);
- }
-
- private setCallback(callback: Callback): void {
- this.banCallback = callback;
- }
-
- private setUserIDs(userIDs: number[]): void {
- this.userIDs = userIDs;
- }
-
- private banSubmit(reason: string, expires: string): void {
- Ajax.apiOnce({
- data: {
- actionName: "ban",
- className: "wcf\\data\\user\\UserAction",
- objectIDs: this.userIDs,
- parameters: {
- banReason: reason,
- banExpires: expires,
- },
- },
- success: this.banCallback,
- });
- }
-
- private cleanupDialog(): void {
- this.reasonInput.value = "";
- this.neverExpiresCheckbox.checked = true;
- DatePicker.clear("userBanExpires");
- this.userBanExpiresSettingsElement.style.setProperty("display", "none", "");
- }
-
- _dialogSetup(): ReturnType {
- return {
- id: "userBanHandler",
- options: {
- onSetup: (content: HTMLElement): void => {
- this.dialogContent = content;
- this.submitElement = content.querySelector(".formSubmitButton")!;
- this.reasonInput = content.querySelector("#userBanReason") as HTMLInputElement;
- this.neverExpiresCheckbox = content.querySelector("#userBanNeverExpires") as HTMLInputElement;
- this.userBanExpiresSettingsElement = content.querySelector("#userBanExpiresSettings")!;
-
- this.submitElement.addEventListener("click", (event) => {
- event.preventDefault();
-
- const expires = this.neverExpiresCheckbox.checked ? "" : DatePicker.getValue("userBanExpires");
- this.banSubmit(this.reasonInput.value, expires);
-
- UiDialog.close(this);
-
- this.cleanupDialog();
- });
-
- this.neverExpiresCheckbox.addEventListener("change", (event) => {
- const checkbox = event.currentTarget as HTMLInputElement;
- if (checkbox.checked) {
- this.userBanExpiresSettingsElement.style.setProperty("display", "none", "");
- } else {
- this.userBanExpiresSettingsElement.style.removeProperty("display");
- }
- });
- },
- title: getPhrase("wcf.acp.user.ban.sure"),
- },
- source: `
-
-
- ${getPhrase(
- "wcf.global.button.submit",
- )}
-
`,
- };
- }
-}
-
-export default BanDialog;
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/User/Action/Handler/Delete.ts b/ts/WoltLabSuite/Core/Acp/Ui/User/Action/Handler/Delete.ts
deleted file mode 100644
index 719e0e5b6c7..00000000000
--- a/ts/WoltLabSuite/Core/Acp/Ui/User/Action/Handler/Delete.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-/**
- * Deletes a given user.
- *
- * @author Joshua Ruesweg
- * @copyright 2001-2021 WoltLab GmbH
- * @license GNU Lesser General Public License
- * @since 5.5
- */
-
-import * as UiConfirmation from "../../../../../Ui/Confirmation";
-import * as Ajax from "../../../../../Ajax";
-import { CallbackSuccess } from "../../../../../Ajax/Data";
-
-export class Delete {
- private userIDs: number[];
- private successCallback: CallbackSuccess;
- private deleteMessage: string;
-
- public constructor(userIDs: number[], successCallback: CallbackSuccess, deleteMessage: string) {
- this.userIDs = userIDs;
- this.successCallback = successCallback;
- this.deleteMessage = deleteMessage;
- }
-
- public delete(): void {
- UiConfirmation.show({
- confirm: () => {
- Ajax.apiOnce({
- data: {
- actionName: "delete",
- className: "wcf\\data\\user\\UserAction",
- objectIDs: this.userIDs,
- },
- success: this.successCallback,
- });
- },
- message: this.deleteMessage,
- messageIsHtml: true,
- });
- }
-}
-
-export default Delete;
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/User/Action/Handler/SendNewPassword.ts b/ts/WoltLabSuite/Core/Acp/Ui/User/Action/Handler/SendNewPassword.ts
index b288c77dc55..8285b76cd84 100644
--- a/ts/WoltLabSuite/Core/Acp/Ui/User/Action/Handler/SendNewPassword.ts
+++ b/ts/WoltLabSuite/Core/Acp/Ui/User/Action/Handler/SendNewPassword.ts
@@ -5,6 +5,7 @@
* @copyright 2001-2021 WoltLab GmbH
* @license GNU Lesser General Public License
* @since 5.5
+ * @deprecated 6.3 Use the `core/users/{id}/send-new-password` endpoint instead.
*/
import { getPhrase } from "WoltLabSuite/Core/Language";
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/User/Action/SendNewPasswordAction.ts b/ts/WoltLabSuite/Core/Acp/Ui/User/Action/SendNewPasswordAction.ts
deleted file mode 100644
index 2474a9c6a8a..00000000000
--- a/ts/WoltLabSuite/Core/Acp/Ui/User/Action/SendNewPasswordAction.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-/**
- * Handles a send new password button.
- *
- * @author Joshua Ruesweg
- * @copyright 2001-2021 WoltLab GmbH
- * @license GNU Lesser General Public License
- * @since 5.5
- */
-
-import AbstractUserAction from "./Abstract";
-import SendNewPassword from "./Handler/SendNewPassword";
-
-export class SendNewPasswordAction extends AbstractUserAction {
- public constructor(button: HTMLElement, userId: number, userDataElement: HTMLElement) {
- super(button, userId, userDataElement);
-
- this.button.addEventListener("click", (event) => {
- event.preventDefault();
-
- const sendNewPasswordHandler = new SendNewPassword([this.userId], () => {
- location.reload();
- });
- sendNewPasswordHandler.send();
- });
- }
-}
-
-export default SendNewPasswordAction;
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/User/Action/ToggleConfirmEmailAction.ts b/ts/WoltLabSuite/Core/Acp/Ui/User/Action/ToggleConfirmEmailAction.ts
deleted file mode 100644
index bd712564ced..00000000000
--- a/ts/WoltLabSuite/Core/Acp/Ui/User/Action/ToggleConfirmEmailAction.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-/**
- * Handles a toggle confirm email button.
- *
- * @author Joshua Ruesweg
- * @copyright 2001-2021 WoltLab GmbH
- * @license GNU Lesser General Public License
- * @since 5.5
- */
-
-import AbstractUserAction from "./Abstract";
-import * as Ajax from "../../../../Ajax";
-import * as Core from "../../../../Core";
-import { AjaxCallbackSetup, DatabaseObjectActionResponse } from "../../../../Ajax/Data";
-import { showDefaultSuccessSnackbar } from "WoltLabSuite/Core/Component/Snackbar";
-
-export class ToggleConfirmEmailAction extends AbstractUserAction {
- public constructor(button: HTMLElement, userId: number, userDataElement: HTMLElement) {
- super(button, userId, userDataElement);
-
- this.button.addEventListener("click", (event) => {
- event.preventDefault();
- const isEmailConfirmed = Core.stringToBool(this.userDataElement.dataset.emailConfirmed!);
-
- Ajax.api(this, {
- actionName: isEmailConfirmed ? "unconfirmEmail" : "confirmEmail",
- });
- });
- }
-
- _ajaxSetup(): ReturnType {
- return {
- data: {
- className: "wcf\\data\\user\\UserAction",
- objectIDs: [this.userId],
- },
- };
- }
-
- _ajaxSuccess(data: DatabaseObjectActionResponse): void {
- data.objectIDs.forEach((objectId) => {
- if (~~objectId == this.userId) {
- switch (data.actionName) {
- case "confirmEmail":
- this.userDataElement.dataset.emailConfirmed = "true";
- this.button.textContent = this.button.dataset.unconfirmEmailMessage!;
- break;
-
- case "unconfirmEmail":
- this.userDataElement.dataset.emailConfirmed = "false";
- this.button.textContent = this.button.dataset.confirmEmailMessage!;
- break;
-
- default:
- throw new Error("Unreachable");
- }
- }
- });
-
- showDefaultSuccessSnackbar();
- }
-}
-
-export default ToggleConfirmEmailAction;
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Clipboard.ts b/ts/WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Clipboard.ts
deleted file mode 100644
index 1f02d3969c1..00000000000
--- a/ts/WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Clipboard.ts
+++ /dev/null
@@ -1,125 +0,0 @@
-/**
- * Handles the user content remove clipboard action.
- *
- * @author Joshua Ruesweg
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License
- * @since 5.4
- */
-
-import AcpUiWorker from "../../../Worker";
-import * as Ajax from "../../../../../Ajax";
-import { getPhrase } from "WoltLabSuite/Core/Language";
-import UiDialog from "../../../../../Ui/Dialog";
-import { AjaxCallbackSetup } from "../../../../../Ajax/Data";
-import { DialogCallbackSetup } from "../../../../../Ui/Dialog/Data";
-import * as EventHandler from "../../../../../Event/Handler";
-
-interface AjaxResponse {
- returnValues: {
- template: string;
- };
-}
-
-interface EventData {
- data: {
- actionName: string;
- internalData: any[];
- label: string;
- parameters: {
- objectIDs: number[];
- url: string;
- };
- };
- listItem: HTMLElement;
-}
-
-export class AcpUserContentRemoveClipboard {
- public userIds: number[];
- private readonly dialogId = "userContentRemoveClipboardPrepareDialog";
-
- /**
- * Initializes the content remove handler.
- */
- constructor() {
- EventHandler.add("com.woltlab.wcf.clipboard", "com.woltlab.wcf.user", (data: EventData) => {
- if (data.data.actionName === "com.woltlab.wcf.user.deleteUserContent") {
- this.userIds = data.data.parameters.objectIDs;
-
- Ajax.api(this);
- }
- });
- }
-
- /**
- * Executes the remove content worker.
- */
- private executeWorker(objectTypes: string[]): void {
- new AcpUiWorker({
- // dialog
- dialogId: "removeContentWorker",
- dialogTitle: getPhrase("wcf.acp.content.removeContent"),
-
- // ajax
- className: "wcf\\system\\worker\\UserContentRemoveWorker",
- parameters: {
- userIDs: this.userIds,
- contentProvider: objectTypes,
- },
- });
- }
-
- /**
- * Handles a click on the submit button in the overlay.
- */
- private submit(): void {
- const objectTypes = Array.from(
- this.dialogContent.querySelectorAll("input.contentProviderObjectType"),
- )
- .filter((element) => element.checked)
- .map((element) => element.name);
-
- UiDialog.close(this.dialogId);
-
- if (objectTypes.length > 0) {
- window.setTimeout(() => {
- this.executeWorker(objectTypes);
- }, 200);
- }
- }
-
- get dialogContent(): HTMLElement {
- return UiDialog.getDialog(this.dialogId)!.content;
- }
-
- _ajaxSuccess(data: AjaxResponse): void {
- UiDialog.open(this, data.returnValues.template);
-
- const submitButton = this.dialogContent.querySelector('input[type="submit"]') as HTMLElement;
- submitButton.addEventListener("click", () => this.submit());
- }
-
- _ajaxSetup(): ReturnType {
- return {
- data: {
- actionName: "prepareRemoveContent",
- className: "wcf\\data\\user\\UserAction",
- parameters: {
- userIDs: this.userIds,
- },
- },
- };
- }
-
- _dialogSetup(): ReturnType {
- return {
- id: this.dialogId,
- options: {
- title: getPhrase("wcf.acp.content.removeContent"),
- },
- source: null,
- };
- }
-}
-
-export default AcpUserContentRemoveClipboard;
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Handler.ts b/ts/WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Handler.ts
deleted file mode 100644
index ebdc312936a..00000000000
--- a/ts/WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Handler.ts
+++ /dev/null
@@ -1,130 +0,0 @@
-/**
- * Provides the trophy icon designer.
- *
- * @author Joshua Ruesweg
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License
- * @since 5.2
- */
-
-import AcpUiWorker from "../../../Worker";
-import * as Ajax from "../../../../../Ajax";
-import { getPhrase } from "WoltLabSuite/Core/Language";
-import UiDialog from "../../../../../Ui/Dialog";
-import { AjaxCallbackSetup } from "../../../../../Ajax/Data";
-import { DialogCallbackSetup } from "../../../../../Ui/Dialog/Data";
-
-type CallbackSuccess = (data: AjaxResponseSuccess) => void;
-
-interface AjaxResponseSuccess {
- loopCount: number;
- parameters: ArbitraryObject;
- proceedURL: string;
- progress: number;
- template?: string;
-}
-
-interface AjaxResponse {
- returnValues: {
- template: string;
- };
-}
-
-class AcpUserContentRemoveHandler {
- private readonly dialogId: string;
- private readonly userId: number;
- private readonly callbackSuccess?: CallbackSuccess;
-
- /**
- * Initializes the content remove handler.
- */
- constructor(element: HTMLElement, userId: number, callbackSuccess?: CallbackSuccess) {
- this.userId = userId;
- this.dialogId = `userRemoveContentHandler-${this.userId}`;
- this.callbackSuccess = callbackSuccess;
-
- element.addEventListener("click", (ev) => this.click(ev));
- }
-
- /**
- * Click on the remove content button.
- */
- private click(event: MouseEvent): void {
- event.preventDefault();
-
- Ajax.api(this);
- }
-
- /**
- * Executes the remove content worker.
- */
- private executeWorker(objectTypes: string[]): void {
- new AcpUiWorker({
- // dialog
- dialogId: "removeContentWorker",
- dialogTitle: getPhrase("wcf.acp.content.removeContent"),
-
- // ajax
- className: "\\wcf\\system\\worker\\UserContentRemoveWorker",
- parameters: {
- userID: this.userId,
- contentProvider: objectTypes,
- },
- callbackSuccess: this.callbackSuccess,
- });
- }
-
- /**
- * Handles a click on the submit button in the overlay.
- */
- private submit(): void {
- const objectTypes = Array.from(
- this.dialogContent.querySelectorAll("input.contentProviderObjectType"),
- )
- .filter((element) => element.checked)
- .map((element) => element.name);
-
- UiDialog.close(this.dialogId);
-
- if (objectTypes.length > 0) {
- window.setTimeout(() => {
- this.executeWorker(objectTypes);
- }, 200);
- }
- }
-
- get dialogContent(): HTMLElement {
- return UiDialog.getDialog(this.dialogId)!.content;
- }
-
- _ajaxSuccess(data: AjaxResponse): void {
- UiDialog.open(this, data.returnValues.template);
-
- const submitButton = this.dialogContent.querySelector('input[type="submit"]') as HTMLElement;
- submitButton.addEventListener("click", () => this.submit());
- }
-
- _ajaxSetup(): ReturnType {
- return {
- data: {
- actionName: "prepareRemoveContent",
- className: "wcf\\data\\user\\UserAction",
- parameters: {
- userID: this.userId,
- },
- },
- };
- }
-
- _dialogSetup(): ReturnType {
- return {
- id: this.dialogId,
- options: {
- title: getPhrase("wcf.acp.content.removeContent"),
- },
- source: null,
- };
- }
-}
-
-export = AcpUserContentRemoveHandler;
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/User/Editor.ts b/ts/WoltLabSuite/Core/Acp/Ui/User/Editor.ts
deleted file mode 100644
index b8626cbdf0f..00000000000
--- a/ts/WoltLabSuite/Core/Acp/Ui/User/Editor.ts
+++ /dev/null
@@ -1,183 +0,0 @@
-/**
- * User editing capabilities for the user list.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License
- */
-
-import AcpUserContentRemoveHandler from "./Content/Remove/Handler";
-import * as Core from "../../../Core";
-import * as EventHandler from "../../../Event/Handler";
-import { getPhrase } from "WoltLabSuite/Core/Language";
-import UiDropdownSimple from "../../../Ui/Dropdown/Simple";
-import DomUtil from "../../../Dom/Util";
-import SendNewPasswordAction from "./Action/SendNewPasswordAction";
-import ToggleConfirmEmailAction from "./Action/ToggleConfirmEmailAction";
-import DisableAction from "./Action/DisableAction";
-import BanAction from "./Action/BanAction";
-import DeleteAction from "./Action/DeleteAction";
-
-interface RefreshUsersData {
- userIds: number[];
-}
-
-class AcpUiUserEditor {
- /**
- * Initializes the edit dropdown for each user.
- */
- constructor() {
- document.querySelectorAll(".jsUserRow").forEach((userRow: HTMLTableRowElement) => this.initUser(userRow));
-
- EventHandler.add("com.woltlab.wcf.acp.user", "refresh", (data: RefreshUsersData) => this.refreshUsers(data));
- }
-
- /**
- * Initializes the edit dropdown for a user.
- */
- private initUser(userRow: HTMLTableRowElement): void {
- const userId = ~~userRow.dataset.objectId!;
- const dropdownId = `userListDropdown${userId}`;
- const dropdownMenu = UiDropdownSimple.getDropdownMenu(dropdownId)!;
- const legacyButtonContainer = userRow.querySelector(".jsLegacyButtons") as HTMLElement;
-
- if (dropdownMenu.childElementCount === 0 && legacyButtonContainer.childElementCount === 0) {
- const toggleButton = userRow.querySelector(".dropdownToggle") as HTMLAnchorElement;
- toggleButton.classList.add("disabled");
-
- return;
- }
-
- UiDropdownSimple.registerCallback(dropdownId, (identifier, action) => {
- if (action === "open") {
- this.rebuild(dropdownMenu, legacyButtonContainer);
- }
- });
-
- const editLink = dropdownMenu.querySelector(".jsEditLink") as HTMLAnchorElement;
- if (editLink !== null) {
- const toggleButton = userRow.querySelector(".dropdownToggle") as HTMLAnchorElement;
- toggleButton.addEventListener("dblclick", (event) => {
- event.preventDefault();
-
- editLink.click();
- });
- }
-
- const deleteContent = dropdownMenu.querySelector(".jsDeleteContent");
- if (deleteContent !== null) {
- new AcpUserContentRemoveHandler(deleteContent as HTMLAnchorElement, userId);
- }
-
- const sendNewPassword = dropdownMenu.querySelector(".jsSendNewPassword");
- if (sendNewPassword !== null) {
- new SendNewPasswordAction(sendNewPassword as HTMLAnchorElement, userId, userRow);
- }
-
- const toggleConfirmEmail = dropdownMenu.querySelector(".jsConfirmEmailToggle");
- if (toggleConfirmEmail !== null) {
- new ToggleConfirmEmailAction(toggleConfirmEmail as HTMLAnchorElement, userId, userRow);
- }
-
- const enableUser = dropdownMenu.querySelector(".jsEnable");
- if (enableUser !== null) {
- new DisableAction(enableUser as HTMLAnchorElement, userId, userRow);
- }
-
- const banUser = dropdownMenu.querySelector(".jsBan");
- if (banUser !== null) {
- new BanAction(banUser as HTMLAnchorElement, userId, userRow);
- }
-
- const deleteUser = dropdownMenu.querySelector(".jsDelete");
- if (deleteUser !== null) {
- new DeleteAction(deleteUser as HTMLAnchorElement, userId, userRow);
- }
- }
-
- /**
- * Rebuilds the dropdown by adding wrapper links for legacy buttons,
- * that will eventually receive the click event.
- */
- private rebuild(dropdownMenu: HTMLElement, legacyButtonContainer: HTMLElement): void {
- dropdownMenu.querySelectorAll(".jsLegacyItem").forEach((element) => element.remove());
-
- // inject buttons
- const items: HTMLLIElement[] = [];
- Array.from(legacyButtonContainer.children).forEach((button: HTMLAnchorElement) => {
- const item = document.createElement("li");
- item.className = "jsLegacyItem";
- item.innerHTML = ' ';
-
- const link = item.children[0] as HTMLAnchorElement;
- link.textContent = button.dataset.tooltip || button.title;
- link.addEventListener("click", (event) => {
- event.preventDefault();
-
- // forward click onto original button
- if (button.nodeName === "A") {
- button.click();
- } else {
- Core.triggerEvent(button, "click");
- }
- });
-
- items.push(item);
- });
-
- items.forEach((item) => {
- dropdownMenu.insertAdjacentElement("afterbegin", item);
- });
-
- // check if there are visible items before each divider
- const listItems = Array.from(dropdownMenu.children) as HTMLElement[];
- listItems.forEach((element) => DomUtil.show(element));
-
- let hasItem = false;
- listItems.forEach((item) => {
- if (item.classList.contains("dropdownDivider")) {
- if (!hasItem) {
- DomUtil.hide(item);
- }
- } else {
- hasItem = true;
- }
- });
- }
-
- private refreshUsers(data: RefreshUsersData): void {
- document.querySelectorAll(".jsUserRow").forEach((userRow: HTMLTableRowElement) => {
- const userId = ~~userRow.dataset.objectId!;
- if (data.userIds.includes(userId)) {
- const userStatusIcons = userRow.querySelector(".userStatusIcons") as HTMLElement;
-
- const banned = Core.stringToBool(userRow.dataset.banned!);
- let iconBanned = userRow.querySelector(".jsUserStatusBanned") as HTMLElement;
- if (banned && iconBanned === null) {
- iconBanned = document.createElement("span");
- iconBanned.innerHTML = ' ';
- iconBanned.classList.add("jsUserStatusBanned", "jsTooltip");
- iconBanned.title = getPhrase("wcf.user.status.banned");
-
- userStatusIcons.appendChild(iconBanned);
- } else if (!banned && iconBanned !== null) {
- iconBanned.remove();
- }
-
- const isDisabled = !Core.stringToBool(userRow.dataset.enabled!);
- let iconIsDisabled = userRow.querySelector(".jsUserStatusIsDisabled") as HTMLElement;
- if (isDisabled && iconIsDisabled === null) {
- iconIsDisabled = document.createElement("span");
- iconIsDisabled.innerHTML = ' ';
- iconIsDisabled.classList.add("jsUserStatusIsDisabled", "jsTooltip");
- iconIsDisabled.title = getPhrase("wcf.user.status.isDisabled");
- userStatusIcons.appendChild(iconIsDisabled);
- } else if (!isDisabled && iconIsDisabled !== null) {
- iconIsDisabled.remove();
- }
- }
- });
- }
-}
-
-export = AcpUiUserEditor;
diff --git a/wcfsetup/install/files/acp/templates/removeUserContentDialog.tpl b/wcfsetup/install/files/acp/templates/removeUserContentDialog.tpl
deleted file mode 100644
index c354e5889a8..00000000000
--- a/wcfsetup/install/files/acp/templates/removeUserContentDialog.tpl
+++ /dev/null
@@ -1,23 +0,0 @@
-
-
{lang}wcf.acp.content.provider.removeContentWarning{/lang}
-
-
-
-
-
-
-
diff --git a/wcfsetup/install/files/acp/templates/userAdd.tpl b/wcfsetup/install/files/acp/templates/userAdd.tpl
index 70c97993106..0ba81abc7f3 100644
--- a/wcfsetup/install/files/acp/templates/userAdd.tpl
+++ b/wcfsetup/install/files/acp/templates/userAdd.tpl
@@ -11,151 +11,14 @@
{if $action === 'edit'}
{hascontent}
- userID}" {*
- *}data-object-id="{$user->getObjectID()}" {*
- *}data-banned="{if $user->banned}true{else}false{/if}" {*
- *}data-enabled="{if !$user->activationCode}true{else}false{/if}" {*
- *}data-email-confirmed="{if $user->isEmailConfirmed()}true{else}false{/if}" {*
- *}>
-
- {icon name='pencil'}
- {lang}wcf.global.button.edit{/lang}
-
-
-
-
+ {content}{unsafe:$interactionContextMenu->render()}{/content}
- {/if}
- {/hascontent}
+ {/hascontent}
+ {/if}
{icon name='list'} {lang}wcf.acp.menu.link.user.list{/lang}
diff --git a/wcfsetup/install/files/acp/templates/userAssignToGroup.tpl b/wcfsetup/install/files/acp/templates/userAssignToGroup.tpl
index 764b62c1b60..2bcf9d09e82 100644
--- a/wcfsetup/install/files/acp/templates/userAssignToGroup.tpl
+++ b/wcfsetup/install/files/acp/templates/userAssignToGroup.tpl
@@ -48,6 +48,7 @@
+ {foreach from=$users item=user} {/foreach}
{csrfToken}
diff --git a/wcfsetup/install/files/acp/templates/userContentRevertChanges.tpl b/wcfsetup/install/files/acp/templates/userContentRevertChanges.tpl
index 619ae8c1c03..b4da692c034 100644
--- a/wcfsetup/install/files/acp/templates/userContentRevertChanges.tpl
+++ b/wcfsetup/install/files/acp/templates/userContentRevertChanges.tpl
@@ -51,6 +51,7 @@
+ {foreach from=$users item=user} {/foreach}
{csrfToken}
diff --git a/wcfsetup/install/files/acp/templates/userEmailAddressExport.tpl b/wcfsetup/install/files/acp/templates/userEmailAddressExport.tpl
index b032e246b20..d6c2e3eb528 100644
--- a/wcfsetup/install/files/acp/templates/userEmailAddressExport.tpl
+++ b/wcfsetup/install/files/acp/templates/userEmailAddressExport.tpl
@@ -59,6 +59,7 @@
+ {foreach from=$users item=user} {/foreach}
{csrfToken}
diff --git a/wcfsetup/install/files/acp/templates/userList.tpl b/wcfsetup/install/files/acp/templates/userList.tpl
index 9007154c0e6..029b9dc737c 100644
--- a/wcfsetup/install/files/acp/templates/userList.tpl
+++ b/wcfsetup/install/files/acp/templates/userList.tpl
@@ -1,247 +1,41 @@
-{if $searchID}
- {assign var='pageTitle' value='wcf.acp.user.search'}
-{else}
- {assign var='pageTitle' value='wcf.acp.user.list'}
-{/if}
-
-{include file='header'}
+{include file='header' pageTitle='wcf.acp.user.list'}
{event name='javascriptInclude'}
-{assign var=encodedURL value=$url|rawurlencode}
-{assign var=encodedAction value=$action|rawurlencode}
-
-{if $pages > 1}
-
-{/if}
-
-{if $users|count}
-
-
-
-{else}
- {lang}wcf.global.noItems{/lang}
-{/if}
+
+ {unsafe:$gridView->render()}
+
{include file='footer'}
diff --git a/wcfsetup/install/files/acp/templates/userMerge.tpl b/wcfsetup/install/files/acp/templates/userMerge.tpl
index acb6b18815f..c5de161a2b7 100644
--- a/wcfsetup/install/files/acp/templates/userMerge.tpl
+++ b/wcfsetup/install/files/acp/templates/userMerge.tpl
@@ -58,6 +58,7 @@
+ {foreach from=$users item=user} {/foreach}
{csrfToken}
diff --git a/wcfsetup/install/files/acp/templates/userSearch.tpl b/wcfsetup/install/files/acp/templates/userSearch.tpl
deleted file mode 100644
index 8fe3a5c0895..00000000000
--- a/wcfsetup/install/files/acp/templates/userSearch.tpl
+++ /dev/null
@@ -1,134 +0,0 @@
-{include file='header' pageTitle='wcf.acp.user.search'}
-
-
-
-
-
-{if $errorField == 'search'}
- {lang}wcf.acp.user.search.error.noMatches{/lang}
-{else}
- {include file='shared_formError'}
-{/if}
-
-
-
-{include file='footer'}
diff --git a/wcfsetup/install/files/acp/templates/usersAwaitingApprovalAcpDashboardBox.tpl b/wcfsetup/install/files/acp/templates/usersAwaitingApprovalAcpDashboardBox.tpl
index 7092b76ac8e..b24f5b75ebb 100644
--- a/wcfsetup/install/files/acp/templates/usersAwaitingApprovalAcpDashboardBox.tpl
+++ b/wcfsetup/install/files/acp/templates/usersAwaitingApprovalAcpDashboardBox.tpl
@@ -21,7 +21,7 @@
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Component/User/RemoveContent.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Component/User/RemoveContent.js
new file mode 100644
index 00000000000..491b5ff8713
--- /dev/null
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Component/User/RemoveContent.js
@@ -0,0 +1,74 @@
+/**
+ * Selects the content of one or more users in a dialog and removes it with
+ * the `UserContentRemoveWorker`.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.3
+ */
+define(["require", "exports", "tslib", "WoltLabSuite/Core/Acp/Ui/Worker", "WoltLabSuite/Core/Component/Dialog", "WoltLabSuite/Core/Component/Snackbar", "WoltLabSuite/Core/Language"], function (require, exports, tslib_1, Worker_1, Dialog_1, Snackbar_1, Language_1) {
+ "use strict";
+ Object.defineProperty(exports, "__esModule", { value: true });
+ exports.setup = setup;
+ exports.setupBulk = setupBulk;
+ Worker_1 = tslib_1.__importDefault(Worker_1);
+ async function removeContent(endpoint) {
+ const { ok, result } = await (0, Dialog_1.dialogFactory)().usingFormBuilder().fromEndpoint(endpoint);
+ if (!ok) {
+ return false;
+ }
+ return new Promise((resolve) => {
+ new Worker_1.default({
+ dialogId: "removeContentWorker",
+ dialogTitle: (0, Language_1.getPhrase)("wcf.acp.content.removeContent"),
+ className: "wcf\\system\\worker\\UserContentRemoveWorker",
+ parameters: {
+ userIDs: result.userIDs,
+ contentProvider: result.contentProviders,
+ },
+ callbackAbort: () => resolve(false),
+ callbackSuccess: () => resolve(true),
+ });
+ });
+ }
+ function setup(identifier, container) {
+ container.addEventListener("interaction:execute", (event) => {
+ if (event.detail.interaction !== identifier) {
+ return;
+ }
+ const element = event.target;
+ void removeContent(event.detail.endpoint).then((ok) => {
+ if (!ok) {
+ return;
+ }
+ // The activity points and the number of likes received may have changed.
+ element.dispatchEvent(new CustomEvent("interaction:invalidate", {
+ bubbles: true,
+ detail: event.detail,
+ }));
+ (0, Snackbar_1.showDefaultSuccessSnackbar)();
+ });
+ });
+ }
+ function setupBulk(identifier, container) {
+ container.addEventListener("bulk-interaction", (event) => {
+ if (event.detail.bulkInteraction !== identifier) {
+ return;
+ }
+ const objectIds = JSON.parse(event.detail.objectIds);
+ void removeContent(event.detail.endpoint).then((ok) => {
+ if (!ok) {
+ return;
+ }
+ for (const objectId of objectIds) {
+ container.querySelector(`[data-object-id="${objectId}"]`)?.dispatchEvent(new CustomEvent("interaction:invalidate", {
+ bubbles: true,
+ }));
+ }
+ (0, Snackbar_1.showDefaultSuccessSnackbar)();
+ container.dispatchEvent(new CustomEvent("interaction:reset-selection"));
+ });
+ });
+ }
+});
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Controller/User/List.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Controller/User/List.js
deleted file mode 100644
index 761b22f26ce..00000000000
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Controller/User/List.js
+++ /dev/null
@@ -1,80 +0,0 @@
-/**
- * Handles the list of users.
- *
- * @author Olaf Braun
- * @copyright 2001-2024 WoltLab GmbH
- * @license GNU Lesser General Public License
- */
-define(["require", "exports", "tslib", "../../../Event/Handler", "WoltLabSuite/Core/Ajax", "WoltLabSuite/Core/Ui/Dropdown/Simple", "WoltLabSuite/Core/Acp/Ui/User/Action/Handler/Ban", "WoltLabSuite/Core/Acp/Ui/User/Action/Handler/SendNewPassword", "WoltLabSuite/Core/Controller/Clipboard", "WoltLabSuite/Core/Acp/Ui/User/Editor", "WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Clipboard", "WoltLabSuite/Core/Component/Snackbar"], function (require, exports, tslib_1, EventHandler, Ajax_1, Simple_1, Ban_1, SendNewPassword_1, Clipboard_1, Editor_1, Clipboard_2, Snackbar_1) {
- "use strict";
- Object.defineProperty(exports, "__esModule", { value: true });
- exports.setup = setup;
- EventHandler = tslib_1.__importStar(EventHandler);
- Simple_1 = tslib_1.__importDefault(Simple_1);
- Ban_1 = tslib_1.__importDefault(Ban_1);
- SendNewPassword_1 = tslib_1.__importDefault(SendNewPassword_1);
- Editor_1 = tslib_1.__importDefault(Editor_1);
- function getUserElements(userIDs) {
- return Array.from(document.querySelectorAll(".jsUserRow")).filter((userRow) => userIDs.includes(parseInt(userRow.dataset.objectId)));
- }
- function getDropdownMenu(userRow) {
- const userId = ~~userRow.dataset.objectId;
- const dropdownId = `userListDropdown${userId}`;
- return Simple_1.default.getDropdownMenu(dropdownId);
- }
- function refresh(userIDs) {
- (0, Clipboard_1.unmark)("com.woltlab.wcf.user", userIDs);
- (0, Snackbar_1.showDefaultSuccessSnackbar)();
- EventHandler.fire("com.woltlab.wcf.acp.user", "refresh", {
- userIds: userIDs,
- });
- }
- async function enableUsers(userIDs) {
- await (0, Ajax_1.dboAction)("enable", "wcf\\data\\user\\UserAction").objectIds(userIDs).dispatch();
- getUserElements(userIDs).forEach((userRow) => {
- userRow.dataset.enabled = "true";
- const button = getDropdownMenu(userRow).querySelector(".jsEnable");
- button.textContent = button.dataset.disableMessage;
- });
- refresh(userIDs);
- }
- function banUsers(userIDs) {
- new Ban_1.default(userIDs).ban(() => {
- getUserElements(userIDs).forEach((userRow) => {
- userRow.dataset.banned = "true";
- const button = getDropdownMenu(userRow).querySelector(".jsBan");
- button.textContent = button.dataset.unbanMessage;
- refresh(userIDs);
- });
- });
- }
- function sendNewPasswords(userIDs) {
- new SendNewPassword_1.default(userIDs, () => {
- refresh(userIDs);
- }).send();
- }
- function setupUserClipboard(hasMarkedItems) {
- (0, Clipboard_1.setup)({
- pageClassName: "wcf\\acp\\page\\UserListPage",
- hasMarkedItems: hasMarkedItems,
- });
- EventHandler.add("com.woltlab.wcf.clipboard", "com.woltlab.wcf.user", (data) => {
- switch (data.data.actionName) {
- case "com.woltlab.wcf.user.enable":
- void enableUsers(data.data.parameters.objectIDs);
- break;
- case "com.woltlab.wcf.user.ban":
- banUsers(data.data.parameters.objectIDs);
- break;
- case "com.woltlab.wcf.user.sendNewPassword":
- sendNewPasswords(data.data.parameters.objectIDs);
- break;
- }
- });
- new Clipboard_2.AcpUserContentRemoveClipboard();
- }
- function setup(hasMarkedItems) {
- setupUserClipboard(hasMarkedItems);
- new Editor_1.default();
- }
-});
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/Abstract.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/Abstract.js
deleted file mode 100644
index 1479871de60..00000000000
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/Abstract.js
+++ /dev/null
@@ -1,25 +0,0 @@
-/**
- * An abstract action, to handle user actions.
- *
- * @author Joshua Ruesweg
- * @copyright 2001-2021 WoltLab GmbH
- * @license GNU Lesser General Public License
- * @since 5.5
- */
-define(["require", "exports"], function (require, exports) {
- "use strict";
- Object.defineProperty(exports, "__esModule", { value: true });
- exports.AbstractUserAction = void 0;
- class AbstractUserAction {
- button;
- userDataElement;
- userId;
- constructor(button, userId, userDataElement) {
- this.button = button;
- this.userId = userId;
- this.userDataElement = userDataElement;
- }
- }
- exports.AbstractUserAction = AbstractUserAction;
- exports.default = AbstractUserAction;
-});
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/BanAction.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/BanAction.js
deleted file mode 100644
index 7db35f8c3f4..00000000000
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/BanAction.js
+++ /dev/null
@@ -1,50 +0,0 @@
-/**
- * Handles a user ban button.
- *
- * @author Joshua Ruesweg
- * @copyright 2001-2021 WoltLab GmbH
- * @license GNU Lesser General Public License
- * @since 5.5
- */
-define(["require", "exports", "tslib", "../../../../Core", "./Abstract", "./Handler/Ban", "../../../../Event/Handler", "WoltLabSuite/Core/Component/Snackbar"], function (require, exports, tslib_1, Core, Abstract_1, Ban_1, EventHandler, Snackbar_1) {
- "use strict";
- Object.defineProperty(exports, "__esModule", { value: true });
- exports.BanAction = void 0;
- Core = tslib_1.__importStar(Core);
- Abstract_1 = tslib_1.__importDefault(Abstract_1);
- Ban_1 = tslib_1.__importDefault(Ban_1);
- EventHandler = tslib_1.__importStar(EventHandler);
- class BanAction extends Abstract_1.default {
- banHandler;
- constructor(button, userId, userDataElement) {
- super(button, userId, userDataElement);
- this.banHandler = new Ban_1.default([this.userId]);
- this.button.addEventListener("click", (event) => {
- event.preventDefault();
- const isBanned = Core.stringToBool(this.userDataElement.dataset.banned);
- if (isBanned) {
- this.banHandler.unban(() => {
- this.userDataElement.dataset.banned = "false";
- this.button.textContent = this.button.dataset.banMessage;
- (0, Snackbar_1.showDefaultSuccessSnackbar)();
- EventHandler.fire("com.woltlab.wcf.acp.user", "refresh", {
- userIds: [this.userId],
- });
- });
- }
- else {
- this.banHandler.ban(() => {
- this.userDataElement.dataset.banned = "true";
- this.button.textContent = this.button.dataset.unbanMessage;
- (0, Snackbar_1.showDefaultSuccessSnackbar)();
- EventHandler.fire("com.woltlab.wcf.acp.user", "refresh", {
- userIds: [this.userId],
- });
- });
- }
- });
- }
- }
- exports.BanAction = BanAction;
- exports.default = BanAction;
-});
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/DeleteAction.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/DeleteAction.js
deleted file mode 100644
index f7ac9bf59a1..00000000000
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/DeleteAction.js
+++ /dev/null
@@ -1,32 +0,0 @@
-/**
- * Handles a user delete button.
- *
- * @author Joshua Ruesweg
- * @copyright 2001-2021 WoltLab GmbH
- * @license GNU Lesser General Public License
- * @since 5.5
- */
-define(["require", "exports", "tslib", "./Abstract", "./Handler/Delete"], function (require, exports, tslib_1, Abstract_1, Delete_1) {
- "use strict";
- Object.defineProperty(exports, "__esModule", { value: true });
- exports.DeleteAction = void 0;
- Abstract_1 = tslib_1.__importDefault(Abstract_1);
- Delete_1 = tslib_1.__importDefault(Delete_1);
- class DeleteAction extends Abstract_1.default {
- constructor(button, userId, userDataElement) {
- super(button, userId, userDataElement);
- if (typeof this.button.dataset.confirmMessage !== "string") {
- throw new Error("The button does not provide a confirmMessage.");
- }
- this.button.addEventListener("click", (event) => {
- event.preventDefault();
- const deleteHandler = new Delete_1.default([this.userId], () => {
- this.userDataElement.remove();
- }, this.button.dataset.confirmMessage);
- deleteHandler.delete();
- });
- }
- }
- exports.DeleteAction = DeleteAction;
- exports.default = DeleteAction;
-});
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/DisableAction.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/DisableAction.js
deleted file mode 100644
index e276e362027..00000000000
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/DisableAction.js
+++ /dev/null
@@ -1,61 +0,0 @@
-/**
- * Handles a user disable/enable button.
- *
- * @author Joshua Ruesweg
- * @copyright 2001-2021 WoltLab GmbH
- * @license GNU Lesser General Public License
- * @since 5.5
- */
-define(["require", "exports", "tslib", "../../../../Ajax", "../../../../Core", "./Abstract", "../../../../Event/Handler", "WoltLabSuite/Core/Component/Snackbar"], function (require, exports, tslib_1, Ajax, Core, Abstract_1, EventHandler, Snackbar_1) {
- "use strict";
- Object.defineProperty(exports, "__esModule", { value: true });
- exports.DisableAction = void 0;
- Ajax = tslib_1.__importStar(Ajax);
- Core = tslib_1.__importStar(Core);
- Abstract_1 = tslib_1.__importDefault(Abstract_1);
- EventHandler = tslib_1.__importStar(EventHandler);
- class DisableAction extends Abstract_1.default {
- constructor(button, userId, userDataElement) {
- super(button, userId, userDataElement);
- this.button.addEventListener("click", (event) => {
- event.preventDefault();
- const isEnabled = Core.stringToBool(this.userDataElement.dataset.enabled);
- Ajax.api(this, {
- actionName: isEnabled ? "disable" : "enable",
- });
- });
- }
- _ajaxSetup() {
- return {
- data: {
- className: "wcf\\data\\user\\UserAction",
- objectIDs: [this.userId],
- },
- };
- }
- _ajaxSuccess(data) {
- data.objectIDs.forEach((objectId) => {
- if (~~objectId == this.userId) {
- switch (data.actionName) {
- case "enable":
- this.userDataElement.dataset.enabled = "true";
- this.button.textContent = this.button.dataset.disableMessage;
- break;
- case "disable":
- this.userDataElement.dataset.enabled = "false";
- this.button.textContent = this.button.dataset.enableMessage;
- break;
- default:
- throw new Error("Unreachable");
- }
- }
- });
- (0, Snackbar_1.showDefaultSuccessSnackbar)();
- EventHandler.fire("com.woltlab.wcf.acp.user", "refresh", {
- userIds: [this.userId],
- });
- }
- }
- exports.DisableAction = DisableAction;
- exports.default = DisableAction;
-});
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/Handler/Ban.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/Handler/Ban.js
deleted file mode 100644
index 5ea725753b0..00000000000
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/Handler/Ban.js
+++ /dev/null
@@ -1,40 +0,0 @@
-/**
- * Handles a user ban.
- *
- * @author Joshua Ruesweg
- * @copyright 2001-2021 WoltLab GmbH
- * @license GNU Lesser General Public License
- * @since 5.5
- */
-define(["require", "exports", "tslib", "../../../../../Ajax", "./Ban/Dialog"], function (require, exports, tslib_1, Ajax, Dialog_1) {
- "use strict";
- Object.defineProperty(exports, "__esModule", { value: true });
- exports.BanHandler = void 0;
- Ajax = tslib_1.__importStar(Ajax);
- Dialog_1 = tslib_1.__importDefault(Dialog_1);
- class BanHandler {
- userIDs;
- constructor(userIDs) {
- this.userIDs = userIDs;
- }
- ban(callback) {
- Dialog_1.default.open(this.userIDs, callback);
- }
- unban(callback) {
- Ajax.api({
- _ajaxSetup: () => {
- return {
- data: {
- actionName: "unban",
- className: "wcf\\data\\user\\UserAction",
- objectIDs: this.userIDs,
- },
- };
- },
- _ajaxSuccess: () => callback(),
- });
- }
- }
- exports.BanHandler = BanHandler;
- exports.default = BanHandler;
-});
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/Handler/Ban/Dialog.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/Handler/Ban/Dialog.js
deleted file mode 100644
index 7a13451a766..00000000000
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/Handler/Ban/Dialog.js
+++ /dev/null
@@ -1,135 +0,0 @@
-/**
- * Creates and handles the dialog to ban a user.
- *
- * @author Joshua Ruesweg
- * @copyright 2001-2021 WoltLab GmbH
- * @license GNU Lesser General Public License
- * @since 5.5
- */
-define(["require", "exports", "tslib", "../../../../../../Ui/Dialog", "WoltLabSuite/Core/Language", "../../../../../../Ajax", "../../../../../../Date/Picker"], function (require, exports, tslib_1, Dialog_1, Language_1, Ajax, Picker_1) {
- "use strict";
- Object.defineProperty(exports, "__esModule", { value: true });
- exports.BanDialog = void 0;
- Dialog_1 = tslib_1.__importDefault(Dialog_1);
- Ajax = tslib_1.__importStar(Ajax);
- Picker_1 = tslib_1.__importDefault(Picker_1);
- class BanDialog {
- static instance;
- banCallback;
- userIDs;
- submitElement;
- neverExpiresCheckbox;
- reasonInput;
- userBanExpiresSettingsElement;
- dialogContent;
- static open(userIDs, callback) {
- if (!BanDialog.instance) {
- BanDialog.instance = new BanDialog();
- }
- BanDialog.instance.setCallback(callback);
- BanDialog.instance.setUserIDs(userIDs);
- BanDialog.instance.openDialog();
- }
- openDialog() {
- Dialog_1.default.open(this);
- }
- setCallback(callback) {
- this.banCallback = callback;
- }
- setUserIDs(userIDs) {
- this.userIDs = userIDs;
- }
- banSubmit(reason, expires) {
- Ajax.apiOnce({
- data: {
- actionName: "ban",
- className: "wcf\\data\\user\\UserAction",
- objectIDs: this.userIDs,
- parameters: {
- banReason: reason,
- banExpires: expires,
- },
- },
- success: this.banCallback,
- });
- }
- cleanupDialog() {
- this.reasonInput.value = "";
- this.neverExpiresCheckbox.checked = true;
- Picker_1.default.clear("userBanExpires");
- this.userBanExpiresSettingsElement.style.setProperty("display", "none", "");
- }
- _dialogSetup() {
- return {
- id: "userBanHandler",
- options: {
- onSetup: (content) => {
- this.dialogContent = content;
- this.submitElement = content.querySelector(".formSubmitButton");
- this.reasonInput = content.querySelector("#userBanReason");
- this.neverExpiresCheckbox = content.querySelector("#userBanNeverExpires");
- this.userBanExpiresSettingsElement = content.querySelector("#userBanExpiresSettings");
- this.submitElement.addEventListener("click", (event) => {
- event.preventDefault();
- const expires = this.neverExpiresCheckbox.checked ? "" : Picker_1.default.getValue("userBanExpires");
- this.banSubmit(this.reasonInput.value, expires);
- Dialog_1.default.close(this);
- this.cleanupDialog();
- });
- this.neverExpiresCheckbox.addEventListener("change", (event) => {
- const checkbox = event.currentTarget;
- if (checkbox.checked) {
- this.userBanExpiresSettingsElement.style.setProperty("display", "none", "");
- }
- else {
- this.userBanExpiresSettingsElement.style.removeProperty("display");
- }
- });
- },
- title: (0, Language_1.getPhrase)("wcf.acp.user.ban.sure"),
- },
- source: `
-
-
- ${(0, Language_1.getPhrase)("wcf.global.button.submit")}
-
`,
- };
- }
- }
- exports.BanDialog = BanDialog;
- exports.default = BanDialog;
-});
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/Handler/Delete.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/Handler/Delete.js
deleted file mode 100644
index 805e588fde3..00000000000
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/Handler/Delete.js
+++ /dev/null
@@ -1,43 +0,0 @@
-/**
- * Deletes a given user.
- *
- * @author Joshua Ruesweg
- * @copyright 2001-2021 WoltLab GmbH
- * @license GNU Lesser General Public License
- * @since 5.5
- */
-define(["require", "exports", "tslib", "../../../../../Ui/Confirmation", "../../../../../Ajax"], function (require, exports, tslib_1, UiConfirmation, Ajax) {
- "use strict";
- Object.defineProperty(exports, "__esModule", { value: true });
- exports.Delete = void 0;
- UiConfirmation = tslib_1.__importStar(UiConfirmation);
- Ajax = tslib_1.__importStar(Ajax);
- class Delete {
- userIDs;
- successCallback;
- deleteMessage;
- constructor(userIDs, successCallback, deleteMessage) {
- this.userIDs = userIDs;
- this.successCallback = successCallback;
- this.deleteMessage = deleteMessage;
- }
- delete() {
- UiConfirmation.show({
- confirm: () => {
- Ajax.apiOnce({
- data: {
- actionName: "delete",
- className: "wcf\\data\\user\\UserAction",
- objectIDs: this.userIDs,
- },
- success: this.successCallback,
- });
- },
- message: this.deleteMessage,
- messageIsHtml: true,
- });
- }
- }
- exports.Delete = Delete;
- exports.default = Delete;
-});
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/Handler/SendNewPassword.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/Handler/SendNewPassword.js
index 649d2c3d41a..f564fcda9ec 100644
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/Handler/SendNewPassword.js
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/Handler/SendNewPassword.js
@@ -5,6 +5,7 @@
* @copyright 2001-2021 WoltLab GmbH
* @license GNU Lesser General Public License
* @since 5.5
+ * @deprecated 6.3 Use the `core/users/{id}/send-new-password` endpoint instead.
*/
define(["require", "exports", "tslib", "WoltLabSuite/Core/Language", "../../../../../Ui/Confirmation", "../../../Worker"], function (require, exports, tslib_1, Language_1, UiConfirmation, Worker_1) {
"use strict";
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/SendNewPasswordAction.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/SendNewPasswordAction.js
deleted file mode 100644
index fef63ba910c..00000000000
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/SendNewPasswordAction.js
+++ /dev/null
@@ -1,29 +0,0 @@
-/**
- * Handles a send new password button.
- *
- * @author Joshua Ruesweg
- * @copyright 2001-2021 WoltLab GmbH
- * @license GNU Lesser General Public License
- * @since 5.5
- */
-define(["require", "exports", "tslib", "./Abstract", "./Handler/SendNewPassword"], function (require, exports, tslib_1, Abstract_1, SendNewPassword_1) {
- "use strict";
- Object.defineProperty(exports, "__esModule", { value: true });
- exports.SendNewPasswordAction = void 0;
- Abstract_1 = tslib_1.__importDefault(Abstract_1);
- SendNewPassword_1 = tslib_1.__importDefault(SendNewPassword_1);
- class SendNewPasswordAction extends Abstract_1.default {
- constructor(button, userId, userDataElement) {
- super(button, userId, userDataElement);
- this.button.addEventListener("click", (event) => {
- event.preventDefault();
- const sendNewPasswordHandler = new SendNewPassword_1.default([this.userId], () => {
- location.reload();
- });
- sendNewPasswordHandler.send();
- });
- }
- }
- exports.SendNewPasswordAction = SendNewPasswordAction;
- exports.default = SendNewPasswordAction;
-});
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/ToggleConfirmEmailAction.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/ToggleConfirmEmailAction.js
deleted file mode 100644
index f328c997484..00000000000
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Action/ToggleConfirmEmailAction.js
+++ /dev/null
@@ -1,57 +0,0 @@
-/**
- * Handles a toggle confirm email button.
- *
- * @author Joshua Ruesweg
- * @copyright 2001-2021 WoltLab GmbH
- * @license GNU Lesser General Public License
- * @since 5.5
- */
-define(["require", "exports", "tslib", "./Abstract", "../../../../Ajax", "../../../../Core", "WoltLabSuite/Core/Component/Snackbar"], function (require, exports, tslib_1, Abstract_1, Ajax, Core, Snackbar_1) {
- "use strict";
- Object.defineProperty(exports, "__esModule", { value: true });
- exports.ToggleConfirmEmailAction = void 0;
- Abstract_1 = tslib_1.__importDefault(Abstract_1);
- Ajax = tslib_1.__importStar(Ajax);
- Core = tslib_1.__importStar(Core);
- class ToggleConfirmEmailAction extends Abstract_1.default {
- constructor(button, userId, userDataElement) {
- super(button, userId, userDataElement);
- this.button.addEventListener("click", (event) => {
- event.preventDefault();
- const isEmailConfirmed = Core.stringToBool(this.userDataElement.dataset.emailConfirmed);
- Ajax.api(this, {
- actionName: isEmailConfirmed ? "unconfirmEmail" : "confirmEmail",
- });
- });
- }
- _ajaxSetup() {
- return {
- data: {
- className: "wcf\\data\\user\\UserAction",
- objectIDs: [this.userId],
- },
- };
- }
- _ajaxSuccess(data) {
- data.objectIDs.forEach((objectId) => {
- if (~~objectId == this.userId) {
- switch (data.actionName) {
- case "confirmEmail":
- this.userDataElement.dataset.emailConfirmed = "true";
- this.button.textContent = this.button.dataset.unconfirmEmailMessage;
- break;
- case "unconfirmEmail":
- this.userDataElement.dataset.emailConfirmed = "false";
- this.button.textContent = this.button.dataset.confirmEmailMessage;
- break;
- default:
- throw new Error("Unreachable");
- }
- }
- });
- (0, Snackbar_1.showDefaultSuccessSnackbar)();
- }
- }
- exports.ToggleConfirmEmailAction = ToggleConfirmEmailAction;
- exports.default = ToggleConfirmEmailAction;
-});
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Clipboard.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Clipboard.js
deleted file mode 100644
index f587c37a971..00000000000
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Clipboard.js
+++ /dev/null
@@ -1,92 +0,0 @@
-/**
- * Handles the user content remove clipboard action.
- *
- * @author Joshua Ruesweg
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License
- * @since 5.4
- */
-define(["require", "exports", "tslib", "../../../Worker", "../../../../../Ajax", "WoltLabSuite/Core/Language", "../../../../../Ui/Dialog", "../../../../../Event/Handler"], function (require, exports, tslib_1, Worker_1, Ajax, Language_1, Dialog_1, EventHandler) {
- "use strict";
- Object.defineProperty(exports, "__esModule", { value: true });
- exports.AcpUserContentRemoveClipboard = void 0;
- Worker_1 = tslib_1.__importDefault(Worker_1);
- Ajax = tslib_1.__importStar(Ajax);
- Dialog_1 = tslib_1.__importDefault(Dialog_1);
- EventHandler = tslib_1.__importStar(EventHandler);
- class AcpUserContentRemoveClipboard {
- userIds;
- dialogId = "userContentRemoveClipboardPrepareDialog";
- /**
- * Initializes the content remove handler.
- */
- constructor() {
- EventHandler.add("com.woltlab.wcf.clipboard", "com.woltlab.wcf.user", (data) => {
- if (data.data.actionName === "com.woltlab.wcf.user.deleteUserContent") {
- this.userIds = data.data.parameters.objectIDs;
- Ajax.api(this);
- }
- });
- }
- /**
- * Executes the remove content worker.
- */
- executeWorker(objectTypes) {
- new Worker_1.default({
- // dialog
- dialogId: "removeContentWorker",
- dialogTitle: (0, Language_1.getPhrase)("wcf.acp.content.removeContent"),
- // ajax
- className: "wcf\\system\\worker\\UserContentRemoveWorker",
- parameters: {
- userIDs: this.userIds,
- contentProvider: objectTypes,
- },
- });
- }
- /**
- * Handles a click on the submit button in the overlay.
- */
- submit() {
- const objectTypes = Array.from(this.dialogContent.querySelectorAll("input.contentProviderObjectType"))
- .filter((element) => element.checked)
- .map((element) => element.name);
- Dialog_1.default.close(this.dialogId);
- if (objectTypes.length > 0) {
- window.setTimeout(() => {
- this.executeWorker(objectTypes);
- }, 200);
- }
- }
- get dialogContent() {
- return Dialog_1.default.getDialog(this.dialogId).content;
- }
- _ajaxSuccess(data) {
- Dialog_1.default.open(this, data.returnValues.template);
- const submitButton = this.dialogContent.querySelector('input[type="submit"]');
- submitButton.addEventListener("click", () => this.submit());
- }
- _ajaxSetup() {
- return {
- data: {
- actionName: "prepareRemoveContent",
- className: "wcf\\data\\user\\UserAction",
- parameters: {
- userIDs: this.userIds,
- },
- },
- };
- }
- _dialogSetup() {
- return {
- id: this.dialogId,
- options: {
- title: (0, Language_1.getPhrase)("wcf.acp.content.removeContent"),
- },
- source: null,
- };
- }
- }
- exports.AcpUserContentRemoveClipboard = AcpUserContentRemoveClipboard;
- exports.default = AcpUserContentRemoveClipboard;
-});
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Handler.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Handler.js
deleted file mode 100644
index faeadb96c4d..00000000000
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Handler.js
+++ /dev/null
@@ -1,95 +0,0 @@
-/**
- * Provides the trophy icon designer.
- *
- * @author Joshua Ruesweg
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License
- * @since 5.2
- */
-define(["require", "exports", "tslib", "../../../Worker", "../../../../../Ajax", "WoltLabSuite/Core/Language", "../../../../../Ui/Dialog"], function (require, exports, tslib_1, Worker_1, Ajax, Language_1, Dialog_1) {
- "use strict";
- Worker_1 = tslib_1.__importDefault(Worker_1);
- Ajax = tslib_1.__importStar(Ajax);
- Dialog_1 = tslib_1.__importDefault(Dialog_1);
- class AcpUserContentRemoveHandler {
- dialogId;
- userId;
- callbackSuccess;
- /**
- * Initializes the content remove handler.
- */
- constructor(element, userId, callbackSuccess) {
- this.userId = userId;
- this.dialogId = `userRemoveContentHandler-${this.userId}`;
- this.callbackSuccess = callbackSuccess;
- element.addEventListener("click", (ev) => this.click(ev));
- }
- /**
- * Click on the remove content button.
- */
- click(event) {
- event.preventDefault();
- Ajax.api(this);
- }
- /**
- * Executes the remove content worker.
- */
- executeWorker(objectTypes) {
- new Worker_1.default({
- // dialog
- dialogId: "removeContentWorker",
- dialogTitle: (0, Language_1.getPhrase)("wcf.acp.content.removeContent"),
- // ajax
- className: "\\wcf\\system\\worker\\UserContentRemoveWorker",
- parameters: {
- userID: this.userId,
- contentProvider: objectTypes,
- },
- callbackSuccess: this.callbackSuccess,
- });
- }
- /**
- * Handles a click on the submit button in the overlay.
- */
- submit() {
- const objectTypes = Array.from(this.dialogContent.querySelectorAll("input.contentProviderObjectType"))
- .filter((element) => element.checked)
- .map((element) => element.name);
- Dialog_1.default.close(this.dialogId);
- if (objectTypes.length > 0) {
- window.setTimeout(() => {
- this.executeWorker(objectTypes);
- }, 200);
- }
- }
- get dialogContent() {
- return Dialog_1.default.getDialog(this.dialogId).content;
- }
- _ajaxSuccess(data) {
- Dialog_1.default.open(this, data.returnValues.template);
- const submitButton = this.dialogContent.querySelector('input[type="submit"]');
- submitButton.addEventListener("click", () => this.submit());
- }
- _ajaxSetup() {
- return {
- data: {
- actionName: "prepareRemoveContent",
- className: "wcf\\data\\user\\UserAction",
- parameters: {
- userID: this.userId,
- },
- },
- };
- }
- _dialogSetup() {
- return {
- id: this.dialogId,
- options: {
- title: (0, Language_1.getPhrase)("wcf.acp.content.removeContent"),
- },
- source: null,
- };
- }
- }
- return AcpUserContentRemoveHandler;
-});
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Editor.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Editor.js
deleted file mode 100644
index 65d2bdf0097..00000000000
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/User/Editor.js
+++ /dev/null
@@ -1,157 +0,0 @@
-/**
- * User editing capabilities for the user list.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License
- */
-define(["require", "exports", "tslib", "./Content/Remove/Handler", "../../../Core", "../../../Event/Handler", "WoltLabSuite/Core/Language", "../../../Ui/Dropdown/Simple", "../../../Dom/Util", "./Action/SendNewPasswordAction", "./Action/ToggleConfirmEmailAction", "./Action/DisableAction", "./Action/BanAction", "./Action/DeleteAction"], function (require, exports, tslib_1, Handler_1, Core, EventHandler, Language_1, Simple_1, Util_1, SendNewPasswordAction_1, ToggleConfirmEmailAction_1, DisableAction_1, BanAction_1, DeleteAction_1) {
- "use strict";
- Handler_1 = tslib_1.__importDefault(Handler_1);
- Core = tslib_1.__importStar(Core);
- EventHandler = tslib_1.__importStar(EventHandler);
- Simple_1 = tslib_1.__importDefault(Simple_1);
- Util_1 = tslib_1.__importDefault(Util_1);
- SendNewPasswordAction_1 = tslib_1.__importDefault(SendNewPasswordAction_1);
- ToggleConfirmEmailAction_1 = tslib_1.__importDefault(ToggleConfirmEmailAction_1);
- DisableAction_1 = tslib_1.__importDefault(DisableAction_1);
- BanAction_1 = tslib_1.__importDefault(BanAction_1);
- DeleteAction_1 = tslib_1.__importDefault(DeleteAction_1);
- class AcpUiUserEditor {
- /**
- * Initializes the edit dropdown for each user.
- */
- constructor() {
- document.querySelectorAll(".jsUserRow").forEach((userRow) => this.initUser(userRow));
- EventHandler.add("com.woltlab.wcf.acp.user", "refresh", (data) => this.refreshUsers(data));
- }
- /**
- * Initializes the edit dropdown for a user.
- */
- initUser(userRow) {
- const userId = ~~userRow.dataset.objectId;
- const dropdownId = `userListDropdown${userId}`;
- const dropdownMenu = Simple_1.default.getDropdownMenu(dropdownId);
- const legacyButtonContainer = userRow.querySelector(".jsLegacyButtons");
- if (dropdownMenu.childElementCount === 0 && legacyButtonContainer.childElementCount === 0) {
- const toggleButton = userRow.querySelector(".dropdownToggle");
- toggleButton.classList.add("disabled");
- return;
- }
- Simple_1.default.registerCallback(dropdownId, (identifier, action) => {
- if (action === "open") {
- this.rebuild(dropdownMenu, legacyButtonContainer);
- }
- });
- const editLink = dropdownMenu.querySelector(".jsEditLink");
- if (editLink !== null) {
- const toggleButton = userRow.querySelector(".dropdownToggle");
- toggleButton.addEventListener("dblclick", (event) => {
- event.preventDefault();
- editLink.click();
- });
- }
- const deleteContent = dropdownMenu.querySelector(".jsDeleteContent");
- if (deleteContent !== null) {
- new Handler_1.default(deleteContent, userId);
- }
- const sendNewPassword = dropdownMenu.querySelector(".jsSendNewPassword");
- if (sendNewPassword !== null) {
- new SendNewPasswordAction_1.default(sendNewPassword, userId, userRow);
- }
- const toggleConfirmEmail = dropdownMenu.querySelector(".jsConfirmEmailToggle");
- if (toggleConfirmEmail !== null) {
- new ToggleConfirmEmailAction_1.default(toggleConfirmEmail, userId, userRow);
- }
- const enableUser = dropdownMenu.querySelector(".jsEnable");
- if (enableUser !== null) {
- new DisableAction_1.default(enableUser, userId, userRow);
- }
- const banUser = dropdownMenu.querySelector(".jsBan");
- if (banUser !== null) {
- new BanAction_1.default(banUser, userId, userRow);
- }
- const deleteUser = dropdownMenu.querySelector(".jsDelete");
- if (deleteUser !== null) {
- new DeleteAction_1.default(deleteUser, userId, userRow);
- }
- }
- /**
- * Rebuilds the dropdown by adding wrapper links for legacy buttons,
- * that will eventually receive the click event.
- */
- rebuild(dropdownMenu, legacyButtonContainer) {
- dropdownMenu.querySelectorAll(".jsLegacyItem").forEach((element) => element.remove());
- // inject buttons
- const items = [];
- Array.from(legacyButtonContainer.children).forEach((button) => {
- const item = document.createElement("li");
- item.className = "jsLegacyItem";
- item.innerHTML = ' ';
- const link = item.children[0];
- link.textContent = button.dataset.tooltip || button.title;
- link.addEventListener("click", (event) => {
- event.preventDefault();
- // forward click onto original button
- if (button.nodeName === "A") {
- button.click();
- }
- else {
- Core.triggerEvent(button, "click");
- }
- });
- items.push(item);
- });
- items.forEach((item) => {
- dropdownMenu.insertAdjacentElement("afterbegin", item);
- });
- // check if there are visible items before each divider
- const listItems = Array.from(dropdownMenu.children);
- listItems.forEach((element) => Util_1.default.show(element));
- let hasItem = false;
- listItems.forEach((item) => {
- if (item.classList.contains("dropdownDivider")) {
- if (!hasItem) {
- Util_1.default.hide(item);
- }
- }
- else {
- hasItem = true;
- }
- });
- }
- refreshUsers(data) {
- document.querySelectorAll(".jsUserRow").forEach((userRow) => {
- const userId = ~~userRow.dataset.objectId;
- if (data.userIds.includes(userId)) {
- const userStatusIcons = userRow.querySelector(".userStatusIcons");
- const banned = Core.stringToBool(userRow.dataset.banned);
- let iconBanned = userRow.querySelector(".jsUserStatusBanned");
- if (banned && iconBanned === null) {
- iconBanned = document.createElement("span");
- iconBanned.innerHTML = ' ';
- iconBanned.classList.add("jsUserStatusBanned", "jsTooltip");
- iconBanned.title = (0, Language_1.getPhrase)("wcf.user.status.banned");
- userStatusIcons.appendChild(iconBanned);
- }
- else if (!banned && iconBanned !== null) {
- iconBanned.remove();
- }
- const isDisabled = !Core.stringToBool(userRow.dataset.enabled);
- let iconIsDisabled = userRow.querySelector(".jsUserStatusIsDisabled");
- if (isDisabled && iconIsDisabled === null) {
- iconIsDisabled = document.createElement("span");
- iconIsDisabled.innerHTML = ' ';
- iconIsDisabled.classList.add("jsUserStatusIsDisabled", "jsTooltip");
- iconIsDisabled.title = (0, Language_1.getPhrase)("wcf.user.status.isDisabled");
- userStatusIcons.appendChild(iconIsDisabled);
- }
- else if (!isDisabled && iconIsDisabled !== null) {
- iconIsDisabled.remove();
- }
- }
- });
- }
- }
- return AcpUiUserEditor;
-});
diff --git a/wcfsetup/install/files/lib/acp/action/UserBanAction.class.php b/wcfsetup/install/files/lib/acp/action/UserBanAction.class.php
new file mode 100644
index 00000000000..323d883a5d0
--- /dev/null
+++ b/wcfsetup/install/files/lib/acp/action/UserBanAction.class.php
@@ -0,0 +1,140 @@
+
+ * @since 6.3
+ */
+final class UserBanAction implements RequestHandlerInterface
+{
+ #[\Override]
+ public function handle(ServerRequestInterface $request): ResponseInterface
+ {
+ $users = $this->getUsers($request);
+
+ $form = $this->getForm();
+
+ if ($request->getMethod() === 'GET') {
+ return $form->toResponse();
+ } elseif ($request->getMethod() === 'POST') {
+ $response = $form->validateRequest($request);
+ if ($response !== null) {
+ return $response;
+ }
+
+ $data = $form->getData()['data'];
+ $banExpires = 0;
+ if (!(bool)$data['banNeverExpires']) {
+ $banExpires = \DateTimeImmutable::createFromFormat(
+ '!Y-m-d',
+ $data['banExpires'],
+ new \DateTimeZone(\TIMEZONE)
+ )->getTimestamp();
+ }
+
+ WCF::getDB()->beginTransaction();
+ foreach ($users as $user) {
+ if ($user->banned === 0) {
+ (new BanUser($user, $data['banReason'], $banExpires))();
+ }
+ }
+ WCF::getDB()->commitTransaction();
+
+ return new JsonResponse([]);
+ } else {
+ throw new \LogicException('Unreachable');
+ }
+ }
+
+ /**
+ * @return User[]
+ */
+ private function getUsers(ServerRequestInterface $request): array
+ {
+ try {
+ $parameters = Helper::mapQueryParameters(
+ $request->getQueryParams(),
+ <<<'EOT'
+ array {
+ id?: positive-int,
+ ids?: positive-int[]
+ }
+ EOT
+ );
+ } catch (MappingError) {
+ throw new IllegalLinkException();
+ }
+
+ $userIDs = $parameters['ids'] ?? (isset($parameters['id']) ? [$parameters['id']] : []);
+ if ($userIDs === []) {
+ throw new IllegalLinkException();
+ }
+
+ $userList = new UserList();
+ $userList->setObjectIDs($userIDs);
+ $userList->readObjects();
+ $users = $userList->getObjects();
+ if (\count($users) !== \count(\array_unique($userIDs))) {
+ throw new IllegalLinkException();
+ }
+
+ foreach ($users as $user) {
+ UserAdministrationPolicy::assertCanBan($user);
+ }
+
+ return $users;
+ }
+
+ private function getForm(): Psr15DialogForm
+ {
+ $form = new Psr15DialogForm(
+ static::class,
+ WCF::getLanguage()->get('wcf.acp.user.ban')
+ );
+ $form->appendChildren([
+ MultilineTextFormField::create('banReason')
+ ->label('wcf.acp.user.banReason')
+ ->description('wcf.acp.user.banReason.description')
+ ->maximumLength(65000),
+ BooleanFormField::create('banNeverExpires')
+ ->label('wcf.acp.user.ban.neverExpires')
+ ->value(true),
+ DateFormField::create('banExpires')
+ ->label('wcf.acp.user.ban.expires')
+ ->description('wcf.acp.user.ban.expires.description')
+ ->saveValueFormat('Y-m-d')
+ ->earliestDate(\TIME_NOW)
+ ->required()
+ ->addDependency(
+ EmptyFormFieldDependency::create('banNeverExpires')
+ ->fieldId('banNeverExpires')
+ ),
+ ]);
+ $form->build();
+
+ return $form;
+ }
+}
diff --git a/wcfsetup/install/files/lib/acp/action/UserContentRemoveAction.class.php b/wcfsetup/install/files/lib/acp/action/UserContentRemoveAction.class.php
new file mode 100644
index 00000000000..3618818666e
--- /dev/null
+++ b/wcfsetup/install/files/lib/acp/action/UserContentRemoveAction.class.php
@@ -0,0 +1,132 @@
+
+ * @since 6.3
+ */
+final class UserContentRemoveAction implements RequestHandlerInterface
+{
+ #[\Override]
+ public function handle(ServerRequestInterface $request): ResponseInterface
+ {
+ $users = $this->getUsers($request);
+
+ $form = $this->getForm();
+
+ if ($request->getMethod() === 'GET') {
+ return $form->toResponse();
+ } elseif ($request->getMethod() === 'POST') {
+ $response = $form->validateRequest($request);
+ if ($response !== null) {
+ return $response;
+ }
+
+ return new JsonResponse([
+ 'result' => [
+ 'userIDs' => \array_keys($users),
+ 'contentProviders' => \array_values($form->getData()['contentProviders']),
+ ],
+ ]);
+ } else {
+ throw new \LogicException('Unreachable');
+ }
+ }
+
+ /**
+ * @return array
+ */
+ private function getUsers(ServerRequestInterface $request): array
+ {
+ try {
+ $parameters = Helper::mapQueryParameters(
+ $request->getQueryParams(),
+ <<<'EOT'
+ array {
+ id?: positive-int,
+ ids?: positive-int[]
+ }
+ EOT
+ );
+ } catch (MappingError) {
+ throw new IllegalLinkException();
+ }
+
+ $userIDs = $parameters['ids'] ?? (isset($parameters['id']) ? [$parameters['id']] : []);
+ if ($userIDs === []) {
+ throw new IllegalLinkException();
+ }
+
+ $userList = new UserList();
+ $userList->setObjectIDs($userIDs);
+ $userList->readObjects();
+ $users = $userList->getObjects();
+ if (\count($users) !== \count(\array_unique($userIDs))) {
+ throw new IllegalLinkException();
+ }
+
+ foreach ($users as $user) {
+ UserAdministrationPolicy::assertCanDelete($user);
+ }
+
+ return $users;
+ }
+
+ private function getForm(): Psr15DialogForm
+ {
+ $contentProviders = \array_filter(
+ ObjectTypeCache::getInstance()->getObjectTypes('com.woltlab.wcf.content.userContentProvider'),
+ static fn(ObjectType $objectType) => $objectType->hidden !== '1'
+ );
+
+ $options = [];
+ foreach ($contentProviders as $objectType) {
+ $options[$objectType->objectType] = WCF::getLanguage()->get(
+ 'wcf.acp.content.provider.' . $objectType->objectType
+ );
+ }
+
+ $form = new Psr15DialogForm(
+ static::class,
+ WCF::getLanguage()->get('wcf.acp.content.removeContent')
+ );
+ $form->appendChildren([
+ NoticeFormNode::create('warning')
+ ->type(NoticeFormNodeType::Warning)
+ ->languageItem('wcf.acp.content.provider.removeContentWarning'),
+ MultipleSelectionFormField::create('contentProviders')
+ ->label('wcf.acp.content.provider.removeContentSectionTitle')
+ ->description('wcf.acp.content.provider.removeContentInfo')
+ ->options($options, false, false)
+ ->value(\array_keys($options))
+ ->required(),
+ ]);
+ $form->build();
+
+ return $form;
+ }
+}
diff --git a/wcfsetup/install/files/lib/acp/action/UserQuickSearchAction.class.php b/wcfsetup/install/files/lib/acp/action/UserQuickSearchAction.class.php
deleted file mode 100644
index 92400a64630..00000000000
--- a/wcfsetup/install/files/lib/acp/action/UserQuickSearchAction.class.php
+++ /dev/null
@@ -1,206 +0,0 @@
-
- */
-final class UserQuickSearchAction extends AbstractAction
-{
- /**
- * @inheritDoc
- */
- public $neededPermissions = ['admin.user.canSearchUser'];
-
- /**
- * search mode
- */
- public string $mode = '';
-
- /**
- * matches
- * @var int[]
- */
- public array $matches = [];
-
- /**
- * results per page
- */
- public int $itemsPerPage = 50;
-
- /**
- * shown columns
- * @var string[]
- */
- public array $columns = ['registrationDate', 'lastActivityTime'];
-
- /**
- * sort field
- */
- public string $sortField = 'username';
-
- /**
- * sort order
- */
- public string $sortOrder = 'ASC';
-
- /**
- * number of results
- */
- public int $maxResults = 2000;
-
- #[\Override]
- public function readParameters(): void
- {
- parent::readParameters();
-
- if (isset($_REQUEST['mode'])) {
- $this->mode = $_REQUEST['mode'];
- }
- }
-
- #[\Override]
- public function execute(): RedirectResponse
- {
- ACPMenu::getInstance()->setActiveMenuItem('wcf.acp.menu.link.user.search');
-
- parent::execute();
-
- // add email column for authorized users
- if (WCF::getSession()->hasPermission('admin.user.canEditMailAddress')) {
- \array_unshift($this->columns, 'email');
- }
-
- switch ($this->mode) {
- case 'banned':
- $sql = "SELECT user_table.userID
- FROM wcf1_user user_table
- LEFT JOIN wcf1_user_option_value option_value
- ON option_value.userID = user_table.userID
- WHERE banned = ?";
- $statement = WCF::getDB()->prepare($sql, $this->maxResults);
- $statement->execute([1]);
- $this->matches = $statement->fetchAll(\PDO::FETCH_COLUMN);
- break;
-
- case 'newest':
- $this->maxResults = 100;
- $this->sortField = 'registrationDate';
- $this->sortOrder = 'DESC';
- $sql = "SELECT user_table.userID
- FROM wcf1_user user_table
- LEFT JOIN wcf1_user_option_value option_value
- ON option_value.userID = user_table.userID
- ORDER BY user_table.registrationDate DESC";
- $statement = WCF::getDB()->prepare($sql, $this->maxResults);
- $statement->execute();
- $this->matches = $statement->fetchAll(\PDO::FETCH_COLUMN);
- break;
-
- case 'disabled':
- $this->sortField = 'registrationDate';
- $this->sortOrder = 'DESC';
- $sql = "SELECT user_table.userID
- FROM wcf1_user user_table
- LEFT JOIN wcf1_user_option_value option_value
- ON option_value.userID = user_table.userID
- WHERE activationCode <> ?
- ORDER BY user_table.registrationDate DESC";
- $statement = WCF::getDB()->prepare($sql, $this->maxResults);
- $statement->execute([0]);
- $this->matches = $statement->fetchAll(\PDO::FETCH_COLUMN);
- break;
-
- case 'pendingActivation':
- $conditionBuilder = new PreparedStatementConditionBuilder();
- $conditionBuilder->add('banned = ?', [0]);
- $conditionBuilder->add('activationCode <> ?', [0]);
- if (((int)\REGISTER_ACTIVATION_METHOD & User::REGISTER_ACTIVATION_USER) !== 0) {
- $conditionBuilder->add('emailConfirmed IS NULL');
- }
-
- $this->sortField = 'registrationDate';
- $this->sortOrder = 'DESC';
- $sql = "SELECT user_table.userID
- FROM wcf1_user user_table
- LEFT JOIN wcf1_user_option_value option_value
- ON option_value.userID = user_table.userID
- " . $conditionBuilder . "
- ORDER BY user_table.registrationDate DESC";
- $statement = WCF::getDB()->prepare($sql, $this->maxResults);
- $statement->execute($conditionBuilder->getParameters());
- $this->matches = $statement->fetchAll(\PDO::FETCH_COLUMN);
- break;
-
- case 'disabledAvatars':
- $sql = "SELECT user_table.userID
- FROM wcf1_user user_table
- LEFT JOIN wcf1_user_option_value option_value
- ON option_value.userID = user_table.userID
- WHERE disableAvatar = ?";
- $statement = WCF::getDB()->prepare($sql, $this->maxResults);
- $statement->execute([1]);
- $this->matches = $statement->fetchAll(\PDO::FETCH_COLUMN);
- break;
-
- case 'disabledSignatures':
- $sql = "SELECT user_table.userID
- FROM wcf1_user user_table
- LEFT JOIN wcf1_user_option_value option_value
- ON option_value.userID = user_table.userID
- WHERE disableSignature = ?";
- $statement = WCF::getDB()->prepare($sql, $this->maxResults);
- $statement->execute([1]);
- $this->matches = $statement->fetchAll(\PDO::FETCH_COLUMN);
- break;
- }
-
- if ($this->matches === []) {
- throw new NamedUserException(
- HtmlString::fromSafeHtml(WCF::getLanguage()->get('wcf.acp.user.search.error.noMatches'))
- );
- }
-
- // store search result in database
- $data = \serialize([
- 'matches' => $this->matches,
- 'itemsPerPage' => $this->itemsPerPage,
- 'columns' => $this->columns,
- ]);
-
- $search = SearchEditor::create([
- 'userID' => WCF::getUser()->userID,
- 'searchData' => $data,
- 'searchTime' => \TIME_NOW,
- 'searchType' => 'users',
- ]);
- $this->executed();
-
- // forward to result page
- $url = LinkHandler::getInstance()->getControllerLink(
- UserListPage::class,
- ['id' => $search->searchID],
- 'sortField=' . \rawurlencode($this->sortField) . '&sortOrder=' . \rawurlencode($this->sortOrder)
- );
-
- return new RedirectResponse(
- $url
- );
- }
-}
diff --git a/wcfsetup/install/files/lib/acp/form/TMultipleUsersForm.class.php b/wcfsetup/install/files/lib/acp/form/TMultipleUsersForm.class.php
new file mode 100644
index 00000000000..96b8d5b3db5
--- /dev/null
+++ b/wcfsetup/install/files/lib/acp/form/TMultipleUsersForm.class.php
@@ -0,0 +1,47 @@
+
+ * @since 6.3
+ */
+trait TMultipleUsersForm
+{
+ /**
+ * @return array
+ * @throws IllegalLinkException if no ids are given or any of the users does not exist
+ */
+ protected function readUsersFromRequest(): array
+ {
+ $userIDs = [];
+ if (isset($_REQUEST['ids']) && \is_array($_REQUEST['ids'])) {
+ $userIDs = \array_unique(ArrayUtil::toIntegerArray($_REQUEST['ids']));
+ }
+
+ if ($userIDs === []) {
+ throw new IllegalLinkException();
+ }
+
+ $userList = new UserList();
+ $userList->setObjectIDs($userIDs);
+ $userList->readObjects();
+ $users = $userList->getObjects();
+
+ if (\count($users) !== \count($userIDs)) {
+ throw new IllegalLinkException();
+ }
+
+ return $users;
+ }
+}
diff --git a/wcfsetup/install/files/lib/acp/form/UserAssignToGroupForm.class.php b/wcfsetup/install/files/lib/acp/form/UserAssignToGroupForm.class.php
index 1f37507e263..42ad5f0df4c 100755
--- a/wcfsetup/install/files/lib/acp/form/UserAssignToGroupForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/UserAssignToGroupForm.class.php
@@ -7,11 +7,9 @@
use wcf\data\user\UserAction;
use wcf\data\user\UserEditor;
use wcf\form\AbstractForm;
-use wcf\system\clipboard\ClipboardHandler;
use wcf\system\database\util\PreparedStatementConditionBuilder;
use wcf\system\exception\IllegalLinkException;
use wcf\system\exception\PermissionDeniedException;
-use wcf\system\exception\SystemException;
use wcf\system\exception\UserInputException;
use wcf\system\WCF;
use wcf\util\ArrayUtil;
@@ -25,6 +23,8 @@
*/
class UserAssignToGroupForm extends AbstractForm
{
+ use TMultipleUsersForm;
+
/**
* @inheritDoc
*/
@@ -59,28 +59,16 @@ class UserAssignToGroupForm extends AbstractForm
*/
public $groups = [];
- /**
- * id of the user clipboard item object type
- * @var ?int
- */
- protected $objectTypeID;
-
#[\Override]
public function readParameters()
{
parent::readParameters();
- // get object type id
- $this->objectTypeID = ClipboardHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.user');
- if ($this->objectTypeID === null) {
- throw new SystemException("clipboard item type 'com.woltlab.wcf.user' is unknown.");
- }
-
- // get user
- // @phpstan-ignore assign.propertyType
- $this->users = ClipboardHandler::getInstance()->getMarkedItems($this->objectTypeID);
- if ($this->users === []) {
- throw new IllegalLinkException();
+ $this->users = $this->readUsersFromRequest();
+ foreach ($this->users as $user) {
+ if (!UserGroup::isAccessibleGroup($user->getGroupIDs())) {
+ throw new PermissionDeniedException();
+ }
}
$this->userIDs = \array_keys($this->users);
@@ -147,8 +135,6 @@ public function save()
$action->executeAction();
}
- ClipboardHandler::getInstance()->removeItems($this->objectTypeID);
-
$this->saved();
WCF::getTPL()->assign([
diff --git a/wcfsetup/install/files/lib/acp/form/UserContentRevertChangesForm.class.php b/wcfsetup/install/files/lib/acp/form/UserContentRevertChangesForm.class.php
index 4ff8346b438..a4891bb78cd 100644
--- a/wcfsetup/install/files/lib/acp/form/UserContentRevertChangesForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/UserContentRevertChangesForm.class.php
@@ -2,11 +2,11 @@
namespace wcf\acp\form;
+use wcf\data\user\group\UserGroup;
use wcf\data\user\User;
use wcf\form\AbstractForm;
-use wcf\system\clipboard\ClipboardHandler;
use wcf\system\edit\EditHistoryManager;
-use wcf\system\exception\IllegalLinkException;
+use wcf\system\exception\PermissionDeniedException;
use wcf\system\exception\UserInputException;
use wcf\system\WCF;
@@ -19,6 +19,8 @@
*/
class UserContentRevertChangesForm extends AbstractForm
{
+ use TMultipleUsersForm;
+
/**
* @inheritDoc
*/
@@ -52,24 +54,16 @@ class UserContentRevertChangesForm extends AbstractForm
*/
public $timeframe = 7;
- /**
- * id of the user clipboard item object type
- * @var ?int
- */
- protected $objectTypeID;
-
#[\Override]
public function readParameters()
{
parent::readParameters();
- // get object type id
- $this->objectTypeID = ClipboardHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.user');
- // get user
- // @phpstan-ignore assign.propertyType
- $this->users = ClipboardHandler::getInstance()->getMarkedItems($this->objectTypeID);
- if ($this->users === []) {
- throw new IllegalLinkException();
+ $this->users = $this->readUsersFromRequest();
+ foreach ($this->users as $user) {
+ if (!UserGroup::isAccessibleGroup($user->getGroupIDs())) {
+ throw new PermissionDeniedException();
+ }
}
$this->userIDs = \array_keys($this->users);
}
@@ -102,7 +96,6 @@ public function save()
EditHistoryManager::getInstance()->bulkRevert($this->userIDs, $this->timeframe * 86400);
// reset clipboard
- ClipboardHandler::getInstance()->removeItems($this->objectTypeID);
$this->saved();
// show success message
diff --git a/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php b/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php
index 946cd915a42..d97d5596bcd 100755
--- a/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php
@@ -2,6 +2,7 @@
namespace wcf\acp\form;
+use wcf\acp\page\UserListPage;
use wcf\data\file\File;
use wcf\data\style\Style;
use wcf\data\user\cover\photo\IUserCoverPhoto;
@@ -9,6 +10,7 @@
use wcf\data\user\User;
use wcf\data\user\UserAction;
use wcf\data\user\UserEditor;
+use wcf\data\user\UserProfile;
use wcf\data\user\UserProfileAction;
use wcf\form\AbstractForm;
use wcf\system\cache\runtime\FileRuntimeCache;
@@ -17,8 +19,11 @@
use wcf\system\exception\PermissionDeniedException;
use wcf\system\exception\UserInputException;
use wcf\system\html\upcast\HtmlUpcastProcessor;
+use wcf\system\interaction\admin\UserInteractions;
+use wcf\system\interaction\StandaloneInteractionContextMenuComponent;
use wcf\system\message\embedded\object\MessageEmbeddedObjectManager;
use wcf\system\moderation\queue\ModerationQueueManager;
+use wcf\system\request\LinkHandler;
use wcf\system\style\StyleHandler;
use wcf\command\user\SetColorScheme;
use wcf\system\user\multifactor\Setup;
@@ -357,10 +362,14 @@ public function assignVariables()
$signatureProcessor->process($this->signature ?: '', 'com.woltlab.wcf.user.signature', $this->user->userID);
WCF::getTPL()->assign([
+ 'interactionContextMenu' => StandaloneInteractionContextMenuComponent::forContentHeaderButton(
+ new UserInteractions(),
+ new UserProfile($this->user->getDecoratedObject()),
+ LinkHandler::getInstance()->getControllerLink(UserListPage::class)
+ ),
'userID' => $this->user->userID,
'action' => 'edit',
'url' => '',
- 'markedUsers' => 0,
'user' => $this->user,
'banned' => $this->banned,
'banReason' => $this->banReason,
diff --git a/wcfsetup/install/files/lib/acp/form/UserEmailAddressExportForm.class.php b/wcfsetup/install/files/lib/acp/form/UserEmailAddressExportForm.class.php
index b24e2ebc0ea..88599610f88 100755
--- a/wcfsetup/install/files/lib/acp/form/UserEmailAddressExportForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/UserEmailAddressExportForm.class.php
@@ -4,10 +4,7 @@
use wcf\data\user\User;
use wcf\form\AbstractForm;
-use wcf\system\clipboard\ClipboardHandler;
use wcf\system\database\util\PreparedStatementConditionBuilder;
-use wcf\system\exception\IllegalLinkException;
-use wcf\system\exception\SystemException;
use wcf\system\WCF;
use wcf\util\StringUtil;
@@ -20,6 +17,8 @@
*/
class UserEmailAddressExportForm extends AbstractForm
{
+ use TMultipleUsersForm;
+
/**
* @inheritDoc
*/
@@ -60,33 +59,13 @@ class UserEmailAddressExportForm extends AbstractForm
*/
public $users = [];
- /**
- * clipboard item type id
- * @var ?int
- */
- protected $objectTypeID;
-
#[\Override]
public function readParameters()
{
parent::readParameters();
- // get object type id
- $this->objectTypeID = ClipboardHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.user');
- if ($this->objectTypeID === null) {
- throw new SystemException("Unknown clipboard item type 'com.woltlab.wcf.user'");
- }
-
- // get user ids
- $users = ClipboardHandler::getInstance()->getMarkedItems($this->objectTypeID);
- /** @var User[] $users */
- if ($users === []) {
- throw new IllegalLinkException();
- }
-
- // load users
- $this->userIDs = \array_keys($users);
- $this->users = $users;
+ $this->users = $this->readUsersFromRequest();
+ $this->userIDs = \array_keys($this->users);
}
#[\Override]
@@ -143,9 +122,6 @@ public function save()
$this->saved();
- // remove items
- ClipboardHandler::getInstance()->removeItems($this->objectTypeID);
-
exit;
}
diff --git a/wcfsetup/install/files/lib/acp/form/UserMailForm.class.php b/wcfsetup/install/files/lib/acp/form/UserMailForm.class.php
index a9dfcd4302e..d696dcbea36 100755
--- a/wcfsetup/install/files/lib/acp/form/UserMailForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/UserMailForm.class.php
@@ -5,8 +5,6 @@
use wcf\data\user\group\UserGroup;
use wcf\form\AbstractForm;
use wcf\form\AbstractFormBuilderForm;
-use wcf\system\clipboard\ClipboardHandler;
-use wcf\system\exception\IllegalLinkException;
use wcf\system\form\builder\field\BooleanFormField;
use wcf\system\form\builder\field\HiddenFormField;
use wcf\system\form\builder\field\MultilineTextFormField;
@@ -126,13 +124,8 @@ private function getUserFormField(): UserFormField
$userID = (int)($_GET['id'] ?? 0);
if ($userID !== 0) {
$formField->value([$userID]);
- } else {
- $objectTypeID = ClipboardHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.user');
- $users = ClipboardHandler::getInstance()->getMarkedItems($objectTypeID);
- if ($users === []) {
- throw new IllegalLinkException();
- }
- $formField->value(\array_keys($users));
+ } elseif (isset($_GET['ids']) && \is_array($_GET['ids'])) {
+ $formField->value(\array_values(\array_unique(\array_map('intval', $_GET['ids']))));
}
}
diff --git a/wcfsetup/install/files/lib/acp/form/UserMergeForm.class.php b/wcfsetup/install/files/lib/acp/form/UserMergeForm.class.php
index e480976ef8d..6de65410737 100644
--- a/wcfsetup/install/files/lib/acp/form/UserMergeForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/UserMergeForm.class.php
@@ -7,7 +7,6 @@
use wcf\data\user\UserAction;
use wcf\data\user\UserEditor;
use wcf\form\AbstractForm;
-use wcf\system\clipboard\ClipboardHandler;
use wcf\system\database\util\PreparedStatementConditionBuilder;
use wcf\system\exception\IllegalLinkException;
use wcf\system\exception\PermissionDeniedException;
@@ -23,6 +22,8 @@
*/
class UserMergeForm extends AbstractForm
{
+ use TMultipleUsersForm;
+
/**
* @inheritDoc
*/
@@ -57,27 +58,16 @@ class UserMergeForm extends AbstractForm
*/
public $mergedUserIDs = [];
- /**
- * id of the user clipboard item object type
- * @var ?int
- */
- protected $objectTypeID;
-
#[\Override]
public function readParameters()
{
parent::readParameters();
- // get object type id
- $this->objectTypeID = ClipboardHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.user');
- // get user
- // @phpstan-ignore assign.propertyType
- $this->users = ClipboardHandler::getInstance()->getMarkedItems($this->objectTypeID);
- if ($this->users === [] || \count($this->users) < 2) {
+ $this->users = $this->readUsersFromRequest();
+ if (\count($this->users) < 2) {
throw new IllegalLinkException();
}
foreach ($this->users as $user) {
- /** @var User $user */
if (!$user->canEdit()) {
throw new PermissionDeniedException();
}
@@ -298,7 +288,6 @@ public function save()
$action->executeAction();
// reset clipboard
- ClipboardHandler::getInstance()->removeItems($this->objectTypeID);
UserEditor::resetCache();
$this->saved();
diff --git a/wcfsetup/install/files/lib/acp/form/UserSearchForm.class.php b/wcfsetup/install/files/lib/acp/form/UserSearchForm.class.php
deleted file mode 100755
index f88b65e4ff7..00000000000
--- a/wcfsetup/install/files/lib/acp/form/UserSearchForm.class.php
+++ /dev/null
@@ -1,305 +0,0 @@
-
- */
-class UserSearchForm extends UserOptionListForm
-{
- /**
- * @inheritDoc
- */
- public $activeMenuItem = 'wcf.acp.menu.link.user.search';
-
- /**
- * @inheritDoc
- */
- public $neededPermissions = ['admin.user.canSearchUser'];
-
- /**
- * list of grouped user group assignment condition object types
- * @var ObjectType[][]
- */
- public $conditions = [];
-
- /**
- * list with searched users
- * @var ?UserList
- */
- public $userList;
-
- /**
- * search id
- * @var int
- */
- public $searchID = 0;
-
- /**
- * sort field
- * @var string
- */
- public $sortField = 'username';
-
- /**
- * sort order
- * @var string
- */
- public $sortOrder = 'ASC';
-
- /**
- * results per page
- * @var int
- */
- public $itemsPerPage = 50;
-
- /**
- * shown columns
- * @var string[]
- */
- public $columns = ['registrationDate', 'lastActivityTime'];
-
- /**
- * number of results
- * @var int
- */
- public $maxResults = 2000;
-
- /**
- * id of the group the users have to belong to
- * is used on the user group list to show all users of a user group
- * @var int
- */
- public $groupID = 0;
-
- #[\Override]
- public function readParameters()
- {
- parent::readParameters();
-
- // search user from passed groupID by group-view
- if (isset($_GET['groupID'])) {
- $this->groupID = \intval($_GET['groupID']);
-
- try {
- // Enforce the visibility of the default columns when filtering the list of
- // users by a user group. This is required to tell searches apart that opted
- // out of any default columns.
- //
- // See 9bc86ecf0bd32ed2615023bcf9ae398aafbb23fa for more context.
- $defaultColumns = $this->columns;
-
- $this->readData();
-
- $this->columns = $defaultColumns;
-
- // add email column for authorized users
- if (WCF::getSession()->hasPermission('admin.user.canEditMailAddress')) {
- \array_unshift($this->columns, 'email');
- }
-
- // disable check for security token for GET requests
- $_POST['t'] = WCF::getSession()->getSecurityToken();
-
- $this->validate();
- $this->save();
- } catch (UserInputException $e) {
- $this->errorField = $e->getField();
- $this->errorType = $e->getType();
- }
- }
- }
-
- #[\Override]
- public function readFormParameters()
- {
- parent::readFormParameters();
-
- foreach ($this->conditions as $conditions) {
- foreach ($conditions as $condition) {
- $condition->getProcessor()->readFormParameters();
- }
- }
-
- if (isset($_POST['itemsPerPage'])) {
- $this->itemsPerPage = \intval($_POST['itemsPerPage']);
- }
- if (isset($_POST['sortField'])) {
- $this->sortField = $_POST['sortField'];
- }
- if (isset($_POST['sortOrder'])) {
- $this->sortOrder = $_POST['sortOrder'];
- }
- if ($_POST !== []) {
- if (isset($_POST['columns']) && \is_array($_POST['columns'])) {
- $this->columns = $_POST['columns'];
- } else {
- $this->columns = [];
- }
- }
- }
-
- #[\Override]
- public function readData()
- {
- $objectTypes = ObjectTypeCache::getInstance()->getObjectTypes('com.woltlab.wcf.condition.userSearch');
- foreach ($objectTypes as $objectType) {
- if ($objectType->conditiongroup === null) {
- continue;
- }
-
- if (!isset($this->conditions[$objectType->conditiongroup])) {
- $this->conditions[$objectType->conditiongroup] = [];
- }
-
- $this->conditions[$objectType->conditiongroup][$objectType->objectTypeID] = $objectType;
- }
-
- parent::readData();
-
- if ($this->hasPsr7Response()) {
- return;
- }
-
- // add email column for authorized users
- if (WCF::getSession()->hasPermission('admin.user.canEditMailAddress')) {
- \array_unshift($this->columns, 'email');
- }
- }
-
- #[\Override]
- public function assignVariables()
- {
- parent::assignVariables();
-
- WCF::getTPL()->assign([
- 'groupedObjectTypes' => $this->conditions,
- 'sortField' => $this->sortField,
- 'sortOrder' => $this->sortOrder,
- 'itemsPerPage' => $this->itemsPerPage,
- 'columns' => $this->columns,
- 'columnOptions' => $this->optionHandler->getCategoryOptions('profile'),
- ]);
- }
-
- #[\Override]
- public function save()
- {
- parent::save();
-
- // store search result in database
- $data = \serialize([
- 'matches' => $this->userList->getObjectIDs(),
- 'itemsPerPage' => $this->itemsPerPage,
- 'columns' => $this->columns,
- ]);
-
- $search = SearchEditor::create([
- 'userID' => WCF::getUser()->userID,
- 'searchData' => $data,
- 'searchTime' => \TIME_NOW,
- 'searchType' => 'users',
- ]);
-
- // get new search id
- $this->searchID = $search->searchID;
- $this->saved();
-
- // forward to result page
- $this->setPsr7Response(new RedirectResponse(
- LinkHandler::getInstance()->getControllerLink(UserListPage::class, [
- 'id' => $this->searchID,
- ], 'sortField=' . \rawurlencode($this->sortField) . '&sortOrder=' . \rawurlencode($this->sortOrder)),
- 303
- ));
- }
-
- #[\Override]
- public function validate()
- {
- AbstractForm::validate();
-
- // remove email column for non-authorized users
- if (
- !WCF::getSession()->hasPermission('admin.user.canEditMailAddress') && ($key = \array_search(
- 'email',
- $this->columns,
- true
- )) !== false
- ) {
- unset($this->columns[$key]);
- }
-
- foreach ($this->conditions as $conditions) {
- /** @var ObjectType $objectType */
- foreach ($conditions as $objectType) {
- /** @var IUserCondition $processor */
- $processor = $objectType->getProcessor();
-
- // manually inject user group data for listing of group members
- if ($this->groupID !== 0 && $objectType->objectType === 'com.woltlab.wcf.userGroup') {
- $userGroups = UserGroup::getSortedAccessibleGroups([], [UserGroup::EVERYONE, UserGroup::GUESTS]);
- /** @var UserGroupCondition $processor */
- $processor->setUserGroups($userGroups);
- $processor->setData(new Condition(null, [
- 'conditionData' => \serialize(['groupIDs' => [$this->groupID]]),
- ]));
- }
- $processor->validate();
- }
- }
-
- $this->search();
-
- if (!\count($this->userList->getObjectIDs())) {
- throw new UserInputException('search', 'noMatches');
- }
- }
-
- /**
- * Search for users which fit to the search values.
- *
- * @return void
- */
- protected function search()
- {
- $this->userList = new UserList();
- $this->userList->sqlConditionJoins .= "
- LEFT JOIN wcf1_user_option_value user_option_value
- ON user_option_value.userID = user_table.userID";
- $this->userList->sqlLimit = $this->maxResults;
-
- EventHandler::getInstance()->fireAction($this, 'search');
-
- // read user ids
- foreach ($this->conditions as $groupedObjectTypes) {
- foreach ($groupedObjectTypes as $objectType) {
- $data = $objectType->getProcessor()->getData();
- if ($data !== null) {
- $objectType->getProcessor()->addObjectListCondition($this->userList, $data);
- }
- }
- }
- $this->userList->readObjectIDs();
- }
-}
diff --git a/wcfsetup/install/files/lib/acp/page/UserListPage.class.php b/wcfsetup/install/files/lib/acp/page/UserListPage.class.php
index cf1a258b3f6..5cda7899d2c 100755
--- a/wcfsetup/install/files/lib/acp/page/UserListPage.class.php
+++ b/wcfsetup/install/files/lib/acp/page/UserListPage.class.php
@@ -2,443 +2,33 @@
namespace wcf\acp\page;
-use wcf\data\DatabaseObject;
-use wcf\data\DatabaseObjectList;
-use wcf\data\user\group\UserGroup;
-use wcf\data\user\option\UserOption;
-use wcf\data\user\option\ViewableUserOption;
-use wcf\data\user\User;
-use wcf\data\user\UserProfile;
-use wcf\page\SortablePage;
-use wcf\system\cache\builder\UserOptionCacheBuilder;
-use wcf\system\cache\runtime\FileRuntimeCache;
-use wcf\system\clipboard\ClipboardHandler;
-use wcf\system\database\util\PreparedStatementConditionBuilder;
-use wcf\system\event\EventHandler;
-use wcf\system\exception\IllegalLinkException;
-use wcf\system\option\IOptionHandler;
-use wcf\system\option\user\IUserOptionOutput;
-use wcf\system\request\LinkHandler;
-use wcf\system\WCF;
-use wcf\util\StringUtil;
+use wcf\page\AbstractGridViewPage;
+use wcf\system\gridView\admin\UserGridView;
/**
- * Shows the result of a user search.
+ * Shows the list of users.
*
- * @author Marcel Werk
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License
+ * @author Marcel Werk
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
*
- * @extends SortablePage>
+ * @extends AbstractGridViewPage
*/
-class UserListPage extends SortablePage
+final class UserListPage extends AbstractGridViewPage
{
- /**
- * list of displayed column names
- * @var string[]
- */
- public $columnHeads = [];
-
- /**
- * list of selected columns
- * @var string[]
- */
- public $columns = ['registrationDate', 'lastActivityTime'];
-
- /**
- * applies special CSS classes for selected columns
- * @var array
- */
- public $columnStyling = [
- 'registrationDate' => 'columnDate',
- 'lastActivityTime' => 'columnDate',
- 'activityPoints' => 'columnDigits',
- 'likesReceived' => 'columnDigits',
- ];
-
- /**
- * list of column values
- * @var string[][]
- */
- public $columnValues = [];
-
/**
* @inheritDoc
*/
- public $defaultSortField = 'username';
-
- /**
- * @inheritDoc
- */
- public $itemsPerPage = 50;
-
- /**
- * list of marked user ids
- * @var int[]
- */
- public $markedUsers = [];
+ public $activeMenuItem = 'wcf.acp.menu.link.user.list';
/**
* @inheritDoc
*/
public $neededPermissions = ['admin.user.canSearchUser'];
- /**
- * IOptionHandler object
- * @var ?IOptionHandler
- */
- protected $optionHandler;
-
- /**
- * list of available user option names
- * @var array
- */
- public $options = [];
-
- /**
- * id of a user search
- * @var int
- */
- public $searchID = 0;
-
- /**
- * list of user ids
- * @var int[]
- */
- public $userIDs = [];
-
- /**
- * list of users
- * @var UserProfile[]
- */
- public $users = [];
-
- /**
- * page url
- * @var string
- */
- public $url = '';
-
- /**
- * condition builder for user filtering
- * @var ?PreparedStatementConditionBuilder
- */
- public $conditions;
-
- /**
- * @inheritDoc
- */
- public $validSortFields = [
- 'userID',
- 'registrationDate',
- 'username',
- 'lastActivityTime',
- 'activityPoints',
- 'likesReceived',
- ];
-
- #[\Override]
- public function readParameters()
- {
- parent::readParameters();
-
- $this->conditions = new PreparedStatementConditionBuilder();
-
- if (!empty($_REQUEST['id'])) {
- $this->searchID = \intval($_REQUEST['id']);
- if ($this->searchID !== 0) {
- $this->readSearchResult();
- }
- if ($this->userIDs === []) {
- throw new IllegalLinkException();
- }
- $this->conditions->add("user_table.userID IN (?)", [$this->userIDs]);
- }
-
- // get user options
- $this->readUserOptions();
- }
-
- #[\Override]
- public function validateSortField()
- {
- // add options to valid sort fields
- $this->validSortFields = \array_merge($this->validSortFields, \array_keys($this->options));
-
- // avoid leaking mail adresses by sorting
- if (WCF::getSession()->hasPermission('admin.user.canEditMailAddress')) {
- $this->validSortFields[] = 'email';
- }
-
- parent::validateSortField();
- }
-
- #[\Override]
- public function readData()
- {
- parent::readData();
-
- // add email column for authorized users
- if ($this->searchID === 0 && WCF::getSession()->hasPermission('admin.user.canEditMailAddress')) {
- \array_unshift($this->columns, 'email');
- }
-
- // get marked users
- $this->markedUsers = WCF::getSession()->getVar('markedUsers');
- if ($this->markedUsers === null || !\is_array($this->markedUsers)) {
- $this->markedUsers = [];
- }
-
- // get columns heads
- $this->readColumnsHeads();
-
- // get users
- $this->readUsers();
-
- // build page url
- $this->url = LinkHandler::getInstance()->getControllerLink(
- UserListPage::class,
- [],
- 'searchID=' . $this->searchID . '&action=' . \rawurlencode($this->action) . '&pageNo=' . $this->pageNo . '&sortField=' . $this->sortField . '&sortOrder=' . $this->sortOrder
- );
- }
-
- #[\Override]
- public function assignVariables()
- {
- parent::assignVariables();
-
- WCF::getTPL()->assign([
- 'users' => $this->users,
- 'searchID' => $this->searchID,
- 'hasMarkedItems' => ClipboardHandler::getInstance()->hasMarkedItems(ClipboardHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.user')),
- 'url' => $this->url,
- 'columnHeads' => $this->columnHeads,
- 'columnValues' => $this->columnValues,
- 'columnStyling' => $this->columnStyling,
- ]);
- }
-
- #[\Override]
- public function show()
- {
- $this->activeMenuItem = 'wcf.acp.menu.link.user.' . ($this->searchID !== 0 ? 'search' : 'list');
-
- parent::show();
- }
-
- #[\Override]
- public function countItems()
- {
- // call countItems event
- EventHandler::getInstance()->fireAction($this, 'countItems');
-
- $sql = "SELECT COUNT(*)
- FROM wcf1_user user_table
- " . $this->conditions;
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute($this->conditions->getParameters());
-
- return $statement->fetchSingleColumn();
- }
-
- /**
- * Fetches the list of results.
- *
- * @return void
- */
- protected function readUsers()
- {
- // get user ids
- $sql = "SELECT user_table.userID
- FROM wcf1_user user_table
- " . (isset($this->options[$this->sortField]) ? "
- LEFT JOIN wcf1_user_option_value user_option_value
- ON user_option_value.userID = user_table.userID
- " : '') . "
- " . $this->conditions . "
- ORDER BY " . (($this->sortField !== 'email' && isset($this->options[$this->sortField])) ? 'user_option_value.userOption' . $this->options[$this->sortField]->optionID : $this->sortField) . " " . $this->sortOrder;
- $statement = WCF::getDB()->prepare(
- $sql,
- $this->itemsPerPage,
- ($this->pageNo - 1) * $this->itemsPerPage
- );
- $statement->execute($this->conditions->getParameters());
- $userIDs = $statement->fetchAll(\PDO::FETCH_COLUMN);
-
- // get user data
- if ($userIDs !== []) {
- // get group ids
- $conditions = new PreparedStatementConditionBuilder();
- $conditions->add("user_table.userID IN (?)", [$userIDs]);
-
- $sql = "SELECT userID, groupID
- FROM wcf1_user_to_group user_table
- " . $conditions;
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute($conditions->getParameters());
- $userToGroups = $statement->fetchMap('userID', 'groupID', false);
-
- $sql = "SELECT option_value.*, user_table.*
- FROM wcf1_user user_table
- LEFT JOIN wcf1_user_option_value option_value
- ON option_value.userID = user_table.userID
- " . $conditions . "
- ORDER BY " . (($this->sortField !== 'email' && isset($this->options[$this->sortField])) ? 'option_value.userOption' . $this->options[$this->sortField]->optionID : 'user_table.' . $this->sortField) . " " . $this->sortOrder;
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute($conditions->getParameters());
-
- $avatarFileIDs = [];
- while ($row = $statement->fetchArray()) {
- $groupIDs = ($userToGroups[$row['userID']] ?? []);
-
- $row['groupIDs'] = \implode(',', $groupIDs);
- $accessible = ($groupIDs !== [] ? UserGroup::isAccessibleGroup($groupIDs) : true);
- $row['accessible'] = $accessible;
- $row['deletable'] = ($accessible && WCF::getSession()->hasPermission('admin.user.canDeleteUser') && $row['userID'] !== WCF::getUser()->userID) ? 1 : 0;
- $row['editable'] = ($accessible && WCF::getSession()->hasPermission('admin.user.canEditUser')) ? 1 : 0;
- $row['bannable'] = ($accessible && WCF::getSession()->hasPermission('admin.user.canBanUser') && $row['userID'] !== WCF::getUser()->userID) ? 1 : 0;
- $row['canBeEnabled'] = ($accessible && WCF::getSession()->hasPermission('admin.user.canEnableUser') && $row['userID'] !== WCF::getUser()->userID) ? 1 : 0;
- $row['isMarked'] = \intval(\in_array($row['userID'], $this->markedUsers, true));
-
- $this->users[] = new UserProfile(new User(null, $row));
-
- if ($row['avatarFileID'] !== null) {
- $avatarFileIDs[] = $row['avatarFileID'];
- }
- }
-
- FileRuntimeCache::getInstance()->cacheObjectIDs($avatarFileIDs);
-
- // get special columns
- foreach ($this->users as $user) {
- foreach ($this->columns as $column) {
- switch ($column) {
- case 'email':
- $this->columnValues[$user->userID][$column] = '' . StringUtil::encodeHTML($user->email) . ' ';
- break;
-
- case 'registrationDate':
- $this->columnValues[$user->userID][$column] = \IntlDateFormatter::formatObject(
- WCF::getUser()->getLocalDate($user->{$column}),
- [
- \IntlDateFormatter::LONG,
- \IntlDateFormatter::NONE,
- ],
- WCF::getLanguage()->getLocale()
- );
- break;
-
- case 'activityPoints':
- case 'likesReceived':
- $this->columnValues[$user->userID][$column] = StringUtil::formatNumeric($user->{$column});
- break;
-
- default:
- if (isset($this->options[$column])) {
- if ($this->options[$column]->outputClass !== '') {
- $this->options[$column]->setOptionValue($user->getDecoratedObject());
-
- /** @var IUserOptionOutput $outputObj */
- $outputObj = $this->options[$column]->getOutputObject();
- $this->columnValues[$user->userID][$column] = $outputObj->getOutput(
- $user->getDecoratedObject(),
- $this->options[$column]->getDecoratedObject(),
- $user->{$column}
- );
- } else {
- $this->columnValues[$user->userID][$column] = StringUtil::encodeHTML($user->{$column});
- }
- }
- break;
- }
- }
- }
- }
- }
-
- /**
- * Fetches the result of the search with the given search id.
- *
- * @return void
- */
- protected function readSearchResult()
- {
- // get user search from database
- $sql = "SELECT searchData
- FROM wcf1_search
- WHERE searchID = ?
- AND userID = ?
- AND searchType = ?";
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute([
- $this->searchID,
- WCF::getUser()->userID,
- 'users',
- ]);
- $search = $statement->fetchArray();
- if (!isset($search['searchData'])) {
- throw new IllegalLinkException();
- }
-
- $data = \unserialize($search['searchData']);
- $this->userIDs = $data['matches'];
- $this->itemsPerPage = $data['itemsPerPage'];
- $this->columns = $data['columns'];
- unset($data);
- }
-
- /**
- * Fetches the user options from cache.
- *
- * @return void
- */
- protected function readUserOptions()
- {
- $this->options = \array_map(
- static fn(UserOption $option) => new ViewableUserOption($option),
- UserOptionCacheBuilder::getInstance()->getData([], 'options')
- );
- }
-
- /**
- * Reads the column heads.
- *
- * @return void
- */
- protected function readColumnsHeads()
- {
- foreach ($this->columns as $column) {
- if ($column === 'likesReceived') {
- $this->columnHeads[$column] = 'wcf.like.likesReceived';
- continue;
- }
- if ($column === 'activityPoints') {
- $this->columnHeads[$column] = 'wcf.user.activityPoint';
- continue;
- }
-
- if (isset($this->options[$column]) && $column !== 'email') {
- // system options keep their `wcf.user.option.*` phrase, options
- // created by an administrator only have their localized title in
- // the l10n storage
- $this->columnHeads[$column] = $this->options[$column]->l10nIdentifier
- ?? $this->options[$column]->getTitle();
- } else {
- $this->columnHeads[$column] = 'wcf.user.' . $column;
- }
- }
- }
-
- #[\Override]
- protected function initObjectList()
- {
- // does nothing
- }
-
#[\Override]
- protected function readObjects()
+ protected function createGridView(): UserGridView
{
- // does nothing
+ return new UserGridView();
}
}
diff --git a/wcfsetup/install/files/lib/command/user/BanUser.class.php b/wcfsetup/install/files/lib/command/user/BanUser.class.php
new file mode 100644
index 00000000000..4109400bf08
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/user/BanUser.class.php
@@ -0,0 +1,34 @@
+
+ * @since 6.3
+ */
+final class BanUser
+{
+ public function __construct(
+ private readonly User $user,
+ private readonly string $reason,
+ /** timestamp at which the ban expires, `0` bans the user permanently */
+ private readonly int $expires = 0,
+ ) {}
+
+ public function __invoke(): User
+ {
+ $builder = UserBuilder::forUpdate($this->user)
+ ->setBanned(true)
+ ->setBanReason($this->reason)
+ ->setBanExpires(\min($this->expires, 2147483647));
+
+ return (new UpdateUser($builder))();
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/user/ConfirmUserEmail.class.php b/wcfsetup/install/files/lib/command/user/ConfirmUserEmail.class.php
new file mode 100644
index 00000000000..5e18625eae9
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/user/ConfirmUserEmail.class.php
@@ -0,0 +1,39 @@
+
+ * @since 6.3
+ */
+final class ConfirmUserEmail
+{
+ public function __construct(
+ private readonly User $user,
+ private readonly bool $sendNotification = true,
+ ) {}
+
+ public function __invoke(): User
+ {
+ $builder = UserBuilder::forUpdate($this->user)
+ ->setEmailConfirmed(null);
+ $user = (new UpdateUser($builder))();
+
+ if (
+ $user->pendingActivation()
+ && ((int)\REGISTER_ACTIVATION_METHOD & User::REGISTER_ACTIVATION_ADMIN) === 0
+ ) {
+ $user = (new EnableUser($user, $this->sendNotification))();
+ }
+
+ return $user;
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/user/DisableUser.class.php b/wcfsetup/install/files/lib/command/user/DisableUser.class.php
new file mode 100644
index 00000000000..64bfade0e58
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/user/DisableUser.class.php
@@ -0,0 +1,55 @@
+
+ * @since 6.3
+ */
+final class DisableUser
+{
+ public function __construct(
+ private readonly User $user,
+ ) {}
+
+ public function __invoke(): User
+ {
+ $builder = UserBuilder::forUpdate($this->user)
+ ->setActivationCode(UserRegistrationUtil::getActivationCode())
+ ->setGroupIDs($this->getGroupIDs());
+
+ // A disabled user with a confirmed email address could not be enabled
+ // through the email confirmation if users can activate themselves.
+ if (((int)\REGISTER_ACTIVATION_METHOD & User::REGISTER_ACTIVATION_ADMIN) === 0) {
+ $builder->setEmailConfirmed(Hex::encode(\random_bytes(20)));
+ }
+
+ return (new UpdateUser($builder))();
+ }
+
+ /**
+ * @return list
+ */
+ private function getGroupIDs(): array
+ {
+ $groupIDs = \array_diff(
+ $this->user->getGroupIDs(true),
+ UserGroup::getGroupIDsByType([UserGroup::USERS])
+ );
+
+ return \array_values(\array_unique(\array_merge(
+ $groupIDs,
+ UserGroup::getGroupIDsByType([UserGroup::GUESTS])
+ )));
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/user/EnableUser.class.php b/wcfsetup/install/files/lib/command/user/EnableUser.class.php
new file mode 100644
index 00000000000..69827a651e3
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/user/EnableUser.class.php
@@ -0,0 +1,82 @@
+
+ * @since 6.3
+ */
+final class EnableUser
+{
+ public function __construct(
+ private readonly User $user,
+ private readonly bool $sendNotification = true,
+ ) {}
+
+ public function __invoke(): User
+ {
+ $builder = UserBuilder::forUpdate($this->user)
+ ->setActivationCode(0)
+ ->setBlacklistMatches([])
+ ->setGroupIDs($this->getGroupIDs());
+
+ if (((int)\REGISTER_ACTIVATION_METHOD & User::REGISTER_ACTIVATION_USER) === 0) {
+ $builder->setEmailConfirmed(null);
+ }
+
+ $user = (new UpdateUser($builder))();
+
+ if ($this->sendNotification) {
+ $this->sendNotification($user);
+ }
+
+ return $user;
+ }
+
+ /**
+ * @return list
+ */
+ private function getGroupIDs(): array
+ {
+ $groupIDs = \array_diff(
+ $this->user->getGroupIDs(true),
+ UserGroup::getGroupIDsByType([UserGroup::GUESTS])
+ );
+
+ return \array_values(\array_unique(\array_merge(
+ $groupIDs,
+ UserGroup::getGroupIDsByType([UserGroup::USERS])
+ )));
+ }
+
+ private function sendNotification(User $user): void
+ {
+ $email = new Email();
+ $email->setMessageID(\sprintf(
+ 'com.woltlab.wcf.adminActivation/%d/%d/%s',
+ $user->userID,
+ \TIME_NOW,
+ \bin2hex(\random_bytes(8))
+ ));
+ $email->addRecipient(new UserMailbox($user));
+ $email->setSubject($user->getLanguage()->getDynamicVariable('wcf.acp.user.activation.mail.subject'));
+ $email->setBody(new MimePartFacade([
+ new RecipientAwareTextMimePart('text/html', 'email_adminActivation'),
+ new RecipientAwareTextMimePart('text/plain', 'email_adminActivation'),
+ ]));
+ $email->send();
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/user/ResendActivationMail.class.php b/wcfsetup/install/files/lib/command/user/ResendActivationMail.class.php
new file mode 100644
index 00000000000..4aea9cd6737
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/user/ResendActivationMail.class.php
@@ -0,0 +1,45 @@
+
+ * @since 6.3
+ */
+final class ResendActivationMail
+{
+ public function __construct(
+ private readonly User $user,
+ ) {}
+
+ public function __invoke(): User
+ {
+ $builder = UserBuilder::forUpdate($this->user)
+ ->setEmailConfirmed(Hex::encode(\random_bytes(20)));
+ $user = (new UpdateUser($builder))();
+
+ $email = new Email();
+ $email->addRecipient(new UserMailbox($user));
+ $email->setSubject($user->getLanguage()->getDynamicVariable('wcf.user.register.needActivation.mail.subject'));
+ $email->setBody(new MimePartFacade([
+ new RecipientAwareTextMimePart('text/html', 'email_registerNeedActivation'),
+ new RecipientAwareTextMimePart('text/plain', 'email_registerNeedActivation'),
+ ]));
+ $email->send();
+
+ return $user;
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/user/SendNewPassword.class.php b/wcfsetup/install/files/lib/command/user/SendNewPassword.class.php
new file mode 100644
index 00000000000..ff549558444
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/user/SendNewPassword.class.php
@@ -0,0 +1,52 @@
+
+ * @since 6.3
+ */
+final class SendNewPassword
+{
+ public function __construct(
+ private readonly User $user,
+ ) {}
+
+ public function __invoke(): void
+ {
+ $lostPasswordKey = Hex::encode(\random_bytes(20));
+
+ $builder = UserBuilder::forUpdate($this->user)
+ ->setPassword(null)
+ ->setLostPasswordKey(\hash('sha256', $lostPasswordKey))
+ ->setLastLostPasswordRequestTime(\TIME_NOW);
+ $user = (new UpdateUser($builder))();
+
+ $email = new Email();
+ $email->setMessageID(\sprintf(
+ 'com.woltlab.wcf.sendNewPassword/%d/%d/%s',
+ $user->userID,
+ \TIME_NOW,
+ \bin2hex(\random_bytes(8))
+ ));
+ $email->addRecipient(new UserMailbox($user));
+ $email->setSubject($user->getLanguage()->getDynamicVariable('wcf.acp.user.sendNewPassword.mail.subject'));
+ $email->setBody(new MimePartFacade([
+ new RecipientAwareTextMimePart('text/html', 'email_sendNewPassword', 'wcf', ['lostPasswordKey' => $lostPasswordKey]),
+ new RecipientAwareTextMimePart('text/plain', 'email_sendNewPassword', 'wcf', ['lostPasswordKey' => $lostPasswordKey]),
+ ]));
+ $email->send();
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/user/UnbanUser.class.php b/wcfsetup/install/files/lib/command/user/UnbanUser.class.php
new file mode 100644
index 00000000000..1e817b07f62
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/user/UnbanUser.class.php
@@ -0,0 +1,30 @@
+
+ * @since 6.3
+ */
+final class UnbanUser
+{
+ public function __construct(
+ private readonly User $user,
+ ) {}
+
+ public function __invoke(): User
+ {
+ $builder = UserBuilder::forUpdate($this->user)
+ ->setBanned(false)
+ ->setBanExpires(0);
+
+ return (new UpdateUser($builder))();
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/user/UnconfirmUserEmail.class.php b/wcfsetup/install/files/lib/command/user/UnconfirmUserEmail.class.php
new file mode 100644
index 00000000000..a362d473ef9
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/user/UnconfirmUserEmail.class.php
@@ -0,0 +1,30 @@
+
+ * @since 6.3
+ */
+final class UnconfirmUserEmail
+{
+ public function __construct(
+ private readonly User $user,
+ ) {}
+
+ public function __invoke(): User
+ {
+ $builder = UserBuilder::forUpdate($this->user)
+ ->setEmailConfirmed(Hex::encode(\random_bytes(20)));
+
+ return (new UpdateUser($builder))();
+ }
+}
diff --git a/wcfsetup/install/files/lib/data/user/UserAction.class.php b/wcfsetup/install/files/lib/data/user/UserAction.class.php
index 84ea108bf26..1bd38bdb754 100644
--- a/wcfsetup/install/files/lib/data/user/UserAction.class.php
+++ b/wcfsetup/install/files/lib/data/user/UserAction.class.php
@@ -2,22 +2,23 @@
namespace wcf\data\user;
-use ParagonIE\ConstantTime\Hex;
use wcf\command\file\DeleteFiles;
+use wcf\command\user\BanUser;
+use wcf\command\user\ConfirmUserEmail;
+use wcf\command\user\DisableUser;
+use wcf\command\user\EnableUser;
+use wcf\command\user\ResendActivationMail;
+use wcf\command\user\UnbanUser;
+use wcf\command\user\UnconfirmUserEmail;
use wcf\data\AbstractDatabaseObjectAction;
use wcf\data\file\File;
use wcf\data\IClipboardAction;
use wcf\data\ISearchAction;
-use wcf\data\object\type\ObjectTypeCache;
use wcf\data\user\group\UserGroup;
use wcf\system\attachment\AttachmentHandler;
use wcf\system\clipboard\ClipboardHandler;
use wcf\system\comment\CommentHandler;
use wcf\system\database\util\PreparedStatementConditionBuilder;
-use wcf\system\email\Email;
-use wcf\system\email\mime\MimePartFacade;
-use wcf\system\email\mime\RecipientAwareTextMimePart;
-use wcf\system\email\UserMailbox;
use wcf\system\event\EventHandler;
use wcf\system\exception\IllegalLinkException;
use wcf\system\exception\PermissionDeniedException;
@@ -28,7 +29,6 @@
use wcf\system\style\FontAwesomeIcon;
use wcf\system\user\group\assignment\UserGroupAssignmentHandler;
use wcf\system\WCF;
-use wcf\util\UserRegistrationUtil;
/**
* Executes user-related actions.
@@ -222,22 +222,13 @@ public function ban()
$banExpires = 0;
}
- $conditionBuilder = new PreparedStatementConditionBuilder();
- $conditionBuilder->add('userID IN (?)', [$this->objectIDs]);
+ if ($this->objects === []) {
+ $this->readObjects();
+ }
- $sql = "UPDATE wcf1_user
- SET banned = ?,
- banReason = ?,
- banExpires = ?
- " . $conditionBuilder;
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute(
- \array_merge([
- 1,
- $this->parameters['banReason'],
- $banExpires,
- ], $conditionBuilder->getParameters())
- );
+ foreach ($this->getObjects() as $userEditor) {
+ (new BanUser($userEditor->getDecoratedObject(), $this->parameters['banReason'], $banExpires))();
+ }
$this->unmarkItems();
@@ -253,20 +244,13 @@ public function ban()
*/
public function unban()
{
- $conditionBuilder = new PreparedStatementConditionBuilder();
- $conditionBuilder->add('userID IN (?)', [$this->objectIDs]);
+ if ($this->objects === []) {
+ $this->readObjects();
+ }
- $sql = "UPDATE wcf1_user
- SET banned = ?,
- banExpires = ?
- " . $conditionBuilder;
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute(
- \array_merge([
- 0,
- 0,
- ], $conditionBuilder->getParameters())
- );
+ foreach ($this->getObjects() as $userEditor) {
+ (new UnbanUser($userEditor->getDecoratedObject()))();
+ }
}
#[\Override]
@@ -692,7 +676,7 @@ public function validateUnconfirmEmail()
}
/**
- * Marks the email address as confirmed. It also enables the user, iff the register method is user activation only.
+ * Marks the email address as confirmed. It also enables the user, if the register method is user activation only.
*
* @return void
* @since 5.3
@@ -703,14 +687,8 @@ public function confirmEmail()
$this->readObjects();
}
- (new self($this->objects, 'update', [
- 'data' => [
- 'emailConfirmed' => null,
- ],
- ]))->executeAction();
-
- if (((int)\REGISTER_ACTIVATION_METHOD & User::REGISTER_ACTIVATION_ADMIN) === 0) {
- $this->enable();
+ foreach ($this->getObjects() as $userEditor) {
+ (new ConfirmUserEmail($userEditor->getDecoratedObject()))();
}
$this->unmarkItems();
@@ -728,12 +706,8 @@ public function unconfirmEmail()
$this->readObjects();
}
- foreach ($this->objects as $object) {
- (new self([$object], 'update', [
- 'data' => [
- 'emailConfirmed' => Hex::encode(\random_bytes(20)),
- ],
- ]))->executeAction();
+ foreach ($this->getObjects() as $userEditor) {
+ (new UnconfirmUserEmail($userEditor->getDecoratedObject()))();
}
$this->unmarkItems();
@@ -750,52 +724,10 @@ public function enable()
$this->readObjects();
}
- $data = [
- 'activationCode' => 0,
- 'blacklistMatches' => '',
- ];
-
- if (((int)\REGISTER_ACTIVATION_METHOD & User::REGISTER_ACTIVATION_USER) === 0) {
- $data['emailConfirmed'] = null;
- }
-
- $action = new self($this->objects, 'update', [
- 'data' => $data,
- 'removeGroups' => UserGroup::getGroupIDsByType([UserGroup::GUESTS]),
- ]);
- $action->executeAction();
- $action = new self($this->objects, 'addToGroups', [
- 'groups' => UserGroup::getGroupIDsByType([UserGroup::USERS]),
- 'deleteOldGroups' => false,
- 'addDefaultGroups' => false,
- ]);
- $action->executeAction();
-
- // send e-mail notification
- if (empty($this->parameters['skipNotification'])) {
- foreach ($this->getObjects() as $user) {
- $email = new Email();
- $email->setMessageID(\sprintf(
- 'com.woltlab.wcf.adminActivation/%d/%d/%s',
- $user->userID,
- \TIME_NOW,
- \bin2hex(\random_bytes(8))
- ));
- $email->addRecipient(new UserMailbox($user->getDecoratedObject()));
- $email->setSubject($user->getLanguage()->getDynamicVariable('wcf.acp.user.activation.mail.subject'));
- $email->setBody(new MimePartFacade([
- new RecipientAwareTextMimePart('text/html', 'email_adminActivation'),
- new RecipientAwareTextMimePart('text/plain', 'email_adminActivation'),
- ]));
- $email->send();
- }
- }
-
- $userIDs = [];
- foreach ($this->getObjects() as $user) {
- $userIDs[] = $user->userID;
+ $sendNotification = !(bool)($this->parameters['skipNotification'] ?? false);
+ foreach ($this->getObjects() as $userEditor) {
+ (new EnableUser($userEditor->getDecoratedObject(), $sendNotification))();
}
- UserGroupAssignmentHandler::getInstance()->checkUsers($userIDs);
$this->unmarkItems();
}
@@ -811,27 +743,9 @@ public function disable()
$this->readObjects();
}
- // We reset the activationCode (which indicates, that the user is not enabled) AND disable the email
- // confirm status, because if the user can enable himself by an email confirmation and we do not reset
- // the email confirmed status, the behavior is undefined, because a user exists, which is not enabled
- // but has a valid email address (Which doesn't usually happen).
- $data = [
- 'activationCode' => UserRegistrationUtil::getActivationCode(),
- ];
- if (((int)\REGISTER_ACTIVATION_METHOD & User::REGISTER_ACTIVATION_ADMIN) === 0) {
- $data['emailConfirmed'] = Hex::encode(\random_bytes(20));
+ foreach ($this->getObjects() as $userEditor) {
+ (new DisableUser($userEditor->getDecoratedObject()))();
}
- $action = new self($this->objects, 'update', [
- 'data' => $data,
- 'removeGroups' => UserGroup::getGroupIDsByType([UserGroup::USERS]),
- ]);
- $action->executeAction();
- $action = new self($this->objects, 'addToGroups', [
- 'groups' => UserGroup::getGroupIDsByType([UserGroup::GUESTS]),
- 'deleteOldGroups' => false,
- 'addDefaultGroups' => false,
- ]);
- $action->executeAction();
$this->unmarkItems();
}
@@ -1098,74 +1012,6 @@ public function enableCoverPhoto()
}
}
- /**
- * Returns the remove content dialog.
- *
- * @return array{template: string}
- * @since 5.2
- */
- public function prepareRemoveContent()
- {
- $knownContentProvider = \array_map(static function ($contentProvider) {
- return $contentProvider->objectType;
- }, \array_filter(
- ObjectTypeCache::getInstance()->getObjectTypes('com.woltlab.wcf.content.userContentProvider'),
- static function ($contentProvider) {
- return $contentProvider->hidden !== '1';
- }
- ));
-
- return [
- 'template' => WCF::getTPL()->render('wcf', 'removeUserContentDialog', [
- 'knownContentProvider' => $knownContentProvider,
- 'userIDs' => $this->parameters['userIDs'],
- 'users' => $this->parameters['users'],
- 'userID' => $this->parameters['userID'] ?? null,
- 'user' => $this->parameters['user'] ?? null,
- ]),
- ];
- }
-
- /**
- * Validates the prepareRemoveContent method.
- *
- * @return void
- * @since 5.2
- */
- public function validatePrepareRemoveContent()
- {
- if (!isset($this->parameters['userIDs']) && isset($this->parameters['userID'])) {
- $this->parameters['userIDs'] = [$this->parameters['userID']];
- }
-
- if (!isset($this->parameters['userIDs']) || !\is_array($this->parameters['userIDs'])) {
- throw new \InvalidArgumentException("Parameter 'userIDs' is missing or invalid.");
- }
-
- $userList = new UserList();
- $userList->setObjectIDs($this->parameters['userIDs']);
- $userList->readObjects();
- $userObjects = $userList->getObjects();
-
- $this->parameters['users'] = [];
- foreach ($this->parameters['userIDs'] as $userID) {
- if (!isset($userObjects[$userID])) {
- throw new \InvalidArgumentException("The userID '" . $userID . "' is unknown.");
- }
-
- if (!$userObjects[$userID]->canEdit()) {
- throw new PermissionDeniedException();
- }
-
- $this->parameters['users'][] = $userObjects[$userID];
- }
-
- if (\count($this->parameters['userIDs']) === 1) {
- $this->parameters['userID'] = \reset($this->parameters['userIDs']);
- $this->parameters['user'] = \reset($this->parameters['users']);
- }
- }
-
/**
* @return void
* @since 5.3
@@ -1220,29 +1066,8 @@ public function validateResendActivationMail()
*/
public function resendActivationMail()
{
- // update every selected user's activation code
- foreach ($this->objects as $object) {
- $action = new self([$object], 'update', [
- 'data' => [
- 'emailConfirmed' => Hex::encode(\random_bytes(20)),
- ],
- ]);
- $action->executeAction();
- }
-
- // get fresh user list with updated user objects
- $newUserList = new UserList();
- $newUserList->getConditionBuilder()->add('user_table.userID IN (?)', [$this->objectIDs]);
- $newUserList->readObjects();
- foreach ($newUserList->getObjects() as $object) {
- $email = new Email();
- $email->addRecipient(new UserMailbox($object));
- $email->setSubject($object->getLanguage()->getDynamicVariable('wcf.user.register.needActivation.mail.subject'));
- $email->setBody(new MimePartFacade([
- new RecipientAwareTextMimePart('text/html', 'email_registerNeedActivation'),
- new RecipientAwareTextMimePart('text/plain', 'email_registerNeedActivation'),
- ]));
- $email->send();
+ foreach ($this->getObjects() as $userEditor) {
+ (new ResendActivationMail($userEditor->getDecoratedObject()))();
}
$this->unmarkItems($this->objectIDs);
diff --git a/wcfsetup/install/files/lib/data/user/UserBuilder.class.php b/wcfsetup/install/files/lib/data/user/UserBuilder.class.php
index 6135e7cdb98..c2cb5fe670d 100644
--- a/wcfsetup/install/files/lib/data/user/UserBuilder.class.php
+++ b/wcfsetup/install/files/lib/data/user/UserBuilder.class.php
@@ -216,6 +216,24 @@ public function setAuthData(string $authData): static
return $this;
}
+ /**
+ * Sets the hash of the key that allows the user to set a new password,
+ * `null` invalidates a pending request.
+ */
+ public function setLostPasswordKey(?string $lostPasswordKeyHash): static
+ {
+ $this->properties['lostPasswordKey'] = $lostPasswordKeyHash;
+
+ return $this;
+ }
+
+ public function setLastLostPasswordRequestTime(int $lastLostPasswordRequestTime): static
+ {
+ $this->properties['lastLostPasswordRequestTime'] = $lastLostPasswordRequestTime;
+
+ return $this;
+ }
+
/**
* Sets the value of a user option, see `UserOption::getFormOption()` for
* serializing form values.
diff --git a/wcfsetup/install/files/lib/event/gridView/admin/UserGridViewInitialized.class.php b/wcfsetup/install/files/lib/event/gridView/admin/UserGridViewInitialized.class.php
new file mode 100644
index 00000000000..8f09878a10b
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/gridView/admin/UserGridViewInitialized.class.php
@@ -0,0 +1,19 @@
+
+ * @since 6.3
+ */
+final class UserGridViewInitialized implements IPsr14Event
+{
+ public function __construct(public readonly UserGridView $gridView) {}
+}
diff --git a/wcfsetup/install/files/lib/event/interaction/admin/UserInteractionCollecting.class.php b/wcfsetup/install/files/lib/event/interaction/admin/UserInteractionCollecting.class.php
new file mode 100644
index 00000000000..b08e0101060
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/interaction/admin/UserInteractionCollecting.class.php
@@ -0,0 +1,19 @@
+
+ * @since 6.3
+ */
+final class UserInteractionCollecting implements IPsr14Event
+{
+ public function __construct(public readonly UserInteractions $provider) {}
+}
diff --git a/wcfsetup/install/files/lib/event/interaction/bulk/admin/UserBulkInteractionCollecting.class.php b/wcfsetup/install/files/lib/event/interaction/bulk/admin/UserBulkInteractionCollecting.class.php
new file mode 100644
index 00000000000..4676b53220d
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/interaction/bulk/admin/UserBulkInteractionCollecting.class.php
@@ -0,0 +1,19 @@
+
+ * @since 6.3
+ */
+final class UserBulkInteractionCollecting implements IPsr14Event
+{
+ public function __construct(public readonly UserBulkInteractions $provider) {}
+}
diff --git a/wcfsetup/install/files/lib/system/clipboard/action/UserClipboardAction.class.php b/wcfsetup/install/files/lib/system/clipboard/action/UserClipboardAction.class.php
deleted file mode 100644
index e57b4057cd2..00000000000
--- a/wcfsetup/install/files/lib/system/clipboard/action/UserClipboardAction.class.php
+++ /dev/null
@@ -1,375 +0,0 @@
-
- *
- * @extends AbstractClipboardAction
- */
-class UserClipboardAction extends AbstractClipboardAction
-{
- /**
- * @inheritDoc
- */
- protected $actionClassActions = ['delete', 'resendActivationMail', 'confirmEmail', 'unconfirmEmail'];
-
- /**
- * @inheritDoc
- */
- protected $supportedActions = [
- 'assignToGroup',
- 'ban',
- 'confirmEmail',
- 'delete',
- 'deleteUserContent',
- 'enable',
- 'exportMailAddress',
- 'merge',
- 'sendMail',
- 'sendNewPassword',
- 'resendActivationMail',
- 'unconfirmEmail',
- ];
-
- #[\Override]
- public function execute(array $objects, ClipboardAction $action)
- {
- $item = parent::execute($objects, $action);
-
- if ($item === null) {
- return null;
- }
-
- // handle actions
- switch ($action->actionName) {
- case 'assignToGroup':
- $item->setURL(LinkHandler::getInstance()->getLink('UserAssignToGroup'));
- break;
-
- case 'delete':
- $item->addInternalData(
- 'confirmMessage',
- WCF::getLanguage()->getDynamicVariable(
- 'wcf.clipboard.item.com.woltlab.wcf.user.delete.confirmMessage',
- [
- 'count' => $item->getCount(),
- ]
- )
- );
- break;
-
- case 'exportMailAddress':
- $item->setURL(LinkHandler::getInstance()->getLink('UserEmailAddressExport'));
- break;
-
- case 'merge':
- $item->setURL(LinkHandler::getInstance()->getLink('UserMerge'));
- break;
-
- case 'sendMail':
- $item->setURL(LinkHandler::getInstance()->getLink('UserMail'));
- break;
-
- case 'sendNewPassword':
- $item->addParameter(
- 'confirmMessage',
- WCF::getLanguage()->getDynamicVariable(
- 'wcf.clipboard.item.com.woltlab.wcf.user.sendNewPassword.confirmMessage',
- [
- 'count' => $item->getCount(),
- ]
- )
- );
- break;
- }
-
- return $item;
- }
-
- #[\Override]
- public function getClassName()
- {
- return UserAction::class;
- }
-
- #[\Override]
- public function getTypeName()
- {
- return 'com.woltlab.wcf.user';
- }
-
- /**
- * Returns the ids of the users which can be deleted.
- *
- * @return int[]
- */
- protected function validateDelete()
- {
- // check permissions
- if (!WCF::getSession()->hasPermission('admin.user.canDeleteUser')) {
- return [];
- }
-
- return $this->__validateAccessibleGroups(\array_keys($this->objects));
- }
-
- /**
- * Returns the ids of the users which can be banned.
- *
- * @return int[]
- */
- protected function validateBan()
- {
- // check permissions
- if (!WCF::getSession()->hasPermission('admin.user.canBanUser')) {
- return [];
- }
-
- $userIDs = [];
- foreach ($this->objects as $user) {
- if ($user->banned === 0) {
- $userIDs[] = $user->userID;
- }
- }
-
- return $this->__validateAccessibleGroups($userIDs);
- }
-
- /**
- * Validates accessible groups.
- *
- * @param int[] $userIDs
- * @return int[]
- */
- protected function __validateAccessibleGroups(array $userIDs, bool $ignoreOwnUser = true)
- {
- if ($ignoreOwnUser) {
- foreach ($userIDs as $index => $userID) {
- if ($userID === WCF::getUser()->userID) {
- unset($userIDs[$index]);
- }
- }
- }
-
- // no valid users found
- if ($userIDs === []) {
- return [];
- }
-
- // fetch user to group associations
- $conditions = new PreparedStatementConditionBuilder();
- $conditions->add("userID IN (?)", [$userIDs]);
-
- $sql = "SELECT userID, groupID
- FROM wcf1_user_to_group
- " . $conditions;
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute($conditions->getParameters());
-
- $userToGroup = $statement->fetchMap('userID', 'groupID', false);
-
- // validate if user's group is accessible for current user
- foreach ($userIDs as $index => $userID) {
- if (!isset($userToGroup[$userID]) || !UserGroup::isAccessibleGroup($userToGroup[$userID])) {
- unset($userIDs[$index]);
- }
- }
-
- return \array_values($userIDs);
- }
-
- /**
- * Returns the ids of the users which can be marked as email confirmed.
- *
- * @return int[]
- * @since 5.3
- */
- protected function validateConfirmEmail()
- {
- // check permissions
- if (!WCF::getSession()->hasPermission('admin.user.canEnableUser')) {
- return [];
- }
-
- $userIDs = [];
- foreach ($this->objects as $user) {
- /** @var User $user */
- if (!$user->isEmailConfirmed()) {
- $userIDs[] = $user->userID;
- }
- }
-
- return $this->__validateAccessibleGroups($userIDs);
- }
-
- /**
- * Returns the ids of the users which can be unmarked as email confirmed.
- *
- * @return int[]
- * @since 5.3
- */
- protected function validateUnconfirmEmail()
- {
- // check permissions
- if (!WCF::getSession()->hasPermission('admin.user.canEnableUser')) {
- return [];
- }
-
- $userIDs = [];
- foreach ($this->objects as $user) {
- /** @var User $user */
- if ($user->isEmailConfirmed()) {
- $userIDs[] = $user->userID;
- }
- }
-
- return $this->__validateAccessibleGroups($userIDs);
- }
-
- /**
- * Returns the ids of the users which can be sent new passwords.
- *
- * @return int[]
- */
- public function validateSendNewPassword()
- {
- // check permissions
- if (!WCF::getSession()->hasPermission('admin.user.canEditPassword')) {
- return [];
- }
-
- $userIDs = [];
- /** @var User $user */
- foreach ($this->objects as $user) {
- if (empty($user->authData)) {
- $userIDs[] = $user->userID;
- }
- }
-
- return $this->__validateAccessibleGroups($userIDs);
- }
-
- /**
- * Returns the ids of the users which can be enabled.
- *
- * @return int[]
- */
- protected function validateEnable()
- {
- // check permissions
- if (!WCF::getSession()->hasPermission('admin.user.canEnableUser')) {
- return [];
- }
-
- $userIDs = [];
- foreach ($this->objects as $user) {
- /** @var User $user */
- if ($user->pendingActivation()) {
- $userIDs[] = $user->userID;
- }
- }
-
- return $userIDs;
- }
-
- /**
- * Returns the ids of the users which can be merge.
- *
- * @return int[]
- */
- protected function validateMerge()
- {
- // check permissions
- if (!WCF::getSession()->hasPermission('admin.user.canEditUser')) {
- return [];
- }
-
- $userIDs = \array_keys($this->objects);
- if (\count($userIDs) < 2) {
- return [];
- }
-
- return $userIDs;
- }
-
- /**
- * Returns the ids of the users which aren't activated.
- *
- * @return int[]
- * @since 5.2
- */
- protected function validateResendActivationMail()
- {
- // check permissions
- if (
- !WCF::getSession()->hasPermission('admin.user.canEnableUser')
- || ((int)\REGISTER_ACTIVATION_METHOD & User::REGISTER_ACTIVATION_USER) === 0
- ) {
- return [];
- }
-
- $userIDs = [];
- foreach ($this->objects as $user) {
- /** @var User $user */
- if ($user->canEmailConfirm()) {
- $userIDs[] = $user->userID;
- }
- }
-
- return $userIDs;
- }
-
- /**
- * Returns the ids of the users whose contents can be deleted.
- *
- * @return int[]
- * @since 5.4
- */
- protected function validateDeleteUserContent()
- {
- if (!WCF::getSession()->hasPermission('admin.user.canDeleteUser')) {
- return [];
- }
-
- return $this->__validateAccessibleGroups(\array_keys($this->objects));
- }
-
- /**
- * @return int[]
- * @since 6.0
- */
- protected function validateSendMail(): array
- {
- if (!WCF::getSession()->hasPermission('admin.user.canMailUser')) {
- return [];
- }
-
- return \array_keys($this->objects);
- }
-
- /**
- * @return int[]
- * @since 6.0
- */
- protected function validateExportMailAddress(): array
- {
- if (!WCF::getSession()->hasPermission('admin.user.canEditMailAddress')) {
- return [];
- }
-
- return \array_keys($this->objects);
- }
-}
diff --git a/wcfsetup/install/files/lib/system/clipboard/action/UserContentClipboardAction.class.php b/wcfsetup/install/files/lib/system/clipboard/action/UserContentClipboardAction.class.php
deleted file mode 100644
index 1215fcb996e..00000000000
--- a/wcfsetup/install/files/lib/system/clipboard/action/UserContentClipboardAction.class.php
+++ /dev/null
@@ -1,81 +0,0 @@
-
- *
- * @extends AbstractClipboardAction
- */
-class UserContentClipboardAction extends AbstractClipboardAction
-{
- /**
- * @inheritDoc
- */
- protected $supportedActions = ['revertContentChanges'];
-
- #[\Override]
- public function execute(array $objects, ClipboardAction $action)
- {
- $item = parent::execute($objects, $action);
-
- if ($item === null) {
- return null;
- }
-
- // handle actions
- switch ($action->actionName) {
- case 'revertContentChanges':
- $item->setURL(LinkHandler::getInstance()->getLink('UserContentRevertChanges'));
- break;
- }
-
- return $item;
- }
-
- #[\Override]
- public function getClassName()
- {
- return UserContentAction::class;
- }
-
- #[\Override]
- public function getTypeName()
- {
- return 'com.woltlab.wcf.user';
- }
-
- /**
- * Returns the ids of the users whose edits can be reverted.
- *
- * @return int[]
- */
- protected function validateRevertContentChanges()
- {
- if (\MODULE_EDIT_HISTORY === 0) {
- return [];
- }
-
- // check permissions
- if (!WCF::getSession()->hasPermission('admin.content.canBulkRevertContentChanges')) {
- return [];
- }
-
- $userIDs = [];
- foreach ($this->objects as $user) {
- $userIDs[] = $user->userID;
- }
-
- return $userIDs;
- }
-}
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/ConfirmUserEmail.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/ConfirmUserEmail.class.php
new file mode 100644
index 00000000000..e4d35f7752d
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/ConfirmUserEmail.class.php
@@ -0,0 +1,38 @@
+
+ * @since 6.3
+ */
+#[PostRequest('/core/users/{id:\d+}/confirm-email')]
+final class ConfirmUserEmail implements IController
+{
+ #[\Override]
+ public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface
+ {
+ $user = Helper::fetchObjectFromRequestParameter($variables['id'], User::class);
+
+ UserAdministrationPolicy::assertCanEnable($user);
+
+ if (!$user->isEmailConfirmed()) {
+ new \wcf\command\user\ConfirmUserEmail($user)();
+ }
+
+ return new JsonResponse([]);
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/DeleteUser.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/DeleteUser.class.php
new file mode 100644
index 00000000000..4ea22c5fbd0
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/DeleteUser.class.php
@@ -0,0 +1,39 @@
+
+ * @since 6.3
+ */
+#[DeleteRequest('/core/users/{id:\d+}')]
+final class DeleteUser implements IController
+{
+ #[\Override]
+ public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface
+ {
+ $user = Helper::fetchObjectFromRequestParameter($variables['id'], User::class);
+
+ UserAdministrationPolicy::assertCanDelete($user);
+
+ // Plugins clean up their data through the events of `UserAction`.
+ $action = new UserAction([$user], 'delete');
+ $action->executeAction();
+
+ return new JsonResponse([]);
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/DisableUser.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/DisableUser.class.php
new file mode 100644
index 00000000000..a5034b18b69
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/DisableUser.class.php
@@ -0,0 +1,38 @@
+
+ * @since 6.3
+ */
+#[PostRequest('/core/users/{id:\d+}/disable')]
+final class DisableUser implements IController
+{
+ #[\Override]
+ public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface
+ {
+ $user = Helper::fetchObjectFromRequestParameter($variables['id'], User::class);
+
+ UserAdministrationPolicy::assertCanEnable($user);
+
+ if (!$user->pendingActivation()) {
+ new \wcf\command\user\DisableUser($user)();
+ }
+
+ return new JsonResponse([]);
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/EnableUser.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/EnableUser.class.php
new file mode 100644
index 00000000000..fa61b836d0d
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/EnableUser.class.php
@@ -0,0 +1,38 @@
+
+ * @since 6.3
+ */
+#[PostRequest('/core/users/{id:\d+}/enable')]
+final class EnableUser implements IController
+{
+ #[\Override]
+ public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface
+ {
+ $user = Helper::fetchObjectFromRequestParameter($variables['id'], User::class);
+
+ UserAdministrationPolicy::assertCanEnable($user);
+
+ if ($user->pendingActivation()) {
+ new \wcf\command\user\EnableUser($user)();
+ }
+
+ return new JsonResponse([]);
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/ResendActivationMail.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/ResendActivationMail.class.php
new file mode 100644
index 00000000000..79476093115
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/ResendActivationMail.class.php
@@ -0,0 +1,36 @@
+
+ * @since 6.3
+ */
+#[PostRequest('/core/users/{id:\d+}/resend-activation-mail')]
+final class ResendActivationMail implements IController
+{
+ #[\Override]
+ public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface
+ {
+ $user = Helper::fetchObjectFromRequestParameter($variables['id'], User::class);
+
+ UserAdministrationPolicy::assertCanResendActivationMail($user);
+
+ new \wcf\command\user\ResendActivationMail($user)();
+
+ return new JsonResponse([]);
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/SendNewPassword.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/SendNewPassword.class.php
new file mode 100644
index 00000000000..bf3cd6bc01a
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/SendNewPassword.class.php
@@ -0,0 +1,36 @@
+
+ * @since 6.3
+ */
+#[PostRequest('/core/users/{id:\d+}/send-new-password')]
+final class SendNewPassword implements IController
+{
+ #[\Override]
+ public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface
+ {
+ $user = Helper::fetchObjectFromRequestParameter($variables['id'], User::class);
+
+ UserAdministrationPolicy::assertCanSendNewPassword($user);
+
+ new \wcf\command\user\SendNewPassword($user)();
+
+ return new JsonResponse([]);
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/UnbanUser.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/UnbanUser.class.php
new file mode 100644
index 00000000000..dd966ae446e
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/UnbanUser.class.php
@@ -0,0 +1,38 @@
+
+ * @since 6.3
+ */
+#[PostRequest('/core/users/{id:\d+}/unban')]
+final class UnbanUser implements IController
+{
+ #[\Override]
+ public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface
+ {
+ $user = Helper::fetchObjectFromRequestParameter($variables['id'], User::class);
+
+ UserAdministrationPolicy::assertCanBan($user);
+
+ if ($user->banned !== 0) {
+ new \wcf\command\user\UnbanUser($user)();
+ }
+
+ return new JsonResponse([]);
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/UnconfirmUserEmail.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/UnconfirmUserEmail.class.php
new file mode 100644
index 00000000000..165f01aefc9
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/UnconfirmUserEmail.class.php
@@ -0,0 +1,38 @@
+
+ * @since 6.3
+ */
+#[PostRequest('/core/users/{id:\d+}/unconfirm-email')]
+final class UnconfirmUserEmail implements IController
+{
+ #[\Override]
+ public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface
+ {
+ $user = Helper::fetchObjectFromRequestParameter($variables['id'], User::class);
+
+ UserAdministrationPolicy::assertCanEnable($user);
+
+ if ($user->isEmailConfirmed()) {
+ new \wcf\command\user\UnconfirmUserEmail($user)();
+ }
+
+ return new JsonResponse([]);
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/event/listener/AcpMenuItemCollectingListener.class.php b/wcfsetup/install/files/lib/system/event/listener/AcpMenuItemCollectingListener.class.php
index 1423522d4ae..37a64d5ad0a 100644
--- a/wcfsetup/install/files/lib/system/event/listener/AcpMenuItemCollectingListener.class.php
+++ b/wcfsetup/install/files/lib/system/event/listener/AcpMenuItemCollectingListener.class.php
@@ -299,12 +299,6 @@ private function addUserItems(ItemCollecting $event): void
parentMenuItem: 'wcf.acp.menu.link.user.management',
link: LinkHandler::getInstance()->getControllerLink(\wcf\acp\page\UserListPage::class),
));
- $event->register(new AcpMenuItem(
- 'wcf.acp.menu.link.user.search',
- parentMenuItem: 'wcf.acp.menu.link.user.list',
- link: LinkHandler::getInstance()->getControllerLink(\wcf\acp\form\UserSearchForm::class),
- icon: FontAwesomeIcon::fromValues('magnifying-glass')
- ));
if (WCF::getSession()->hasPermission('admin.user.canAddUser')) {
$event->register(new AcpMenuItem(
'wcf.acp.menu.link.user.add',
diff --git a/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php
new file mode 100644
index 00000000000..7f1fc75d8f6
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php
@@ -0,0 +1,399 @@
+
+ * @since 6.3
+ *
+ * @extends AbstractGridView
+ */
+final class UserGridView extends AbstractGridView
+{
+ use TUserOptionsByCategory;
+
+ private bool $userStorageLoaded = false;
+
+ public function __construct()
+ {
+ $this->addColumns([
+ GridViewColumn::for('userID')
+ ->label('wcf.global.objectID')
+ ->renderer(new ObjectIdColumnRenderer())
+ ->sortable(),
+ GridViewColumn::for('avatar')
+ ->label('wcf.user.avatar')
+ ->renderer($this->getAvatarRenderer()),
+ GridViewColumn::for('username')
+ ->label('wcf.user.username')
+ ->titleColumn()
+ ->renderer($this->getUsernameRenderer())
+ ->filter(TextFilter::class)
+ ->sortable(),
+ ]);
+
+ if (WCF::getSession()->hasPermission('admin.user.canEditMailAddress')) {
+ $this->addColumn(
+ GridViewColumn::for('email')
+ ->label('wcf.user.email')
+ ->renderer(new EmailColumnRenderer())
+ ->filter(TextFilter::class)
+ ->sortable()
+ );
+ }
+
+ $this->addColumn(
+ GridViewColumn::for('registrationDate')
+ ->label('wcf.user.registrationDate')
+ ->renderer(new TimeColumnRenderer())
+ ->filter(TimeFilter::class)
+ ->sortable(defaultSortOrder: 'DESC')
+ );
+
+ if (WCF::getSession()->hasPermission('admin.user.canViewIpAddress')) {
+ $this->addColumn(
+ GridViewColumn::for('registrationIpAddress')
+ ->label('wcf.user.registrationIpAddress')
+ ->renderer(new IpAddressColumnRenderer())
+ ->filter(IpAddressFilter::class)
+ );
+ }
+
+ $this->addColumns([
+ GridViewColumn::for('lastActivityTime')
+ ->label('wcf.user.lastActivityTime')
+ ->renderer($this->getLastActivityTimeRenderer())
+ ->filter(TimeFilter::class)
+ ->sortable(defaultSortOrder: 'DESC'),
+ GridViewColumn::for('activityPoints')
+ ->label('wcf.user.activityPoint')
+ ->renderer(new NumberColumnRenderer())
+ ->filter(IntegerFilter::class)
+ ->sortable(defaultSortOrder: 'DESC'),
+ GridViewColumn::for('likesReceived')
+ ->label('wcf.like.likesReceived')
+ ->renderer(new NumberColumnRenderer())
+ ->filter(IntegerFilter::class)
+ ->sortable(defaultSortOrder: 'DESC'),
+ ]);
+
+ $this->addAvailableFilters([
+ $this->getGroupFilter(),
+ new BooleanFilter('banned', 'wcf.acp.user.filter.banned'),
+ $this->getIsDisabledFilter(),
+ $this->getPendingActivationFilter(),
+ $this->getEmailConfirmedFilter(),
+ new BooleanFilter('disableAvatar', 'wcf.acp.user.filter.disableAvatar'),
+ new BooleanFilter('disableSignature', 'wcf.acp.user.filter.disableSignature'),
+ ...$this->getUserOptionFilters(),
+ ]);
+
+ $provider = new UserInteractions();
+ $provider->addInteractions([
+ new Divider(),
+ new EditInteraction(UserEditForm::class, static fn(UserProfile $user) => $user->canEdit()),
+ ]);
+ $this->setInteractionProvider($provider);
+ $this->setBulkInteractionProvider(new UserBulkInteractions());
+
+ $this->addRowLink(
+ new GridViewRowLink(
+ UserEditForm::class,
+ isAvailableCallback: static fn(UserProfile $user) => $user->canEdit()
+ )
+ );
+
+ $this->setDefaultSortField('username');
+ $this->setRowsPerPage(50);
+ }
+
+ #[\Override]
+ public function isAccessible(): bool
+ {
+ return WCF::getSession()->hasPermission('admin.user.canSearchUser');
+ }
+
+ #[\Override]
+ public function getRows(): array
+ {
+ $rows = parent::getRows();
+
+ // The interactions check the groups of each user.
+ if (!$this->userStorageLoaded) {
+ $this->userStorageLoaded = true;
+ if ($rows !== []) {
+ UserStorageHandler::getInstance()->loadStorage(\array_keys($rows));
+ }
+ }
+
+ return $rows;
+ }
+
+ #[\Override]
+ protected function createObjectList(): UserProfileList
+ {
+ return new UserProfileList();
+ }
+
+ #[\Override]
+ protected function getInitializedEvent(): UserGridViewInitialized
+ {
+ return new UserGridViewInitialized($this);
+ }
+
+ private function getAvatarRenderer(): AbstractColumnRenderer
+ {
+ return new class extends AbstractColumnRenderer {
+ #[\Override]
+ public function prepare(mixed $value, DatabaseObject $row): void
+ {
+ \assert($row instanceof UserProfile);
+
+ FileRuntimeCache::getInstance()->cacheObjectID($row->avatarFileID);
+ }
+
+ #[\Override]
+ public function render(mixed $value, DatabaseObject $row): string
+ {
+ \assert($row instanceof UserProfile);
+
+ return $row->getAvatar()->getImageTag(24);
+ }
+
+ #[\Override]
+ public function getClasses(): string
+ {
+ return 'gridView__column--icon';
+ }
+ };
+ }
+
+ private function getUsernameRenderer(): DefaultColumnRenderer
+ {
+ return new class extends DefaultColumnRenderer {
+ #[\Override]
+ public function render(mixed $value, DatabaseObject $row): string
+ {
+ \assert($row instanceof UserProfile);
+
+ $html = '' . StringUtil::encodeHTML($row->username) . ' ';
+
+ $statusIcons = '';
+ if ($row->banned !== 0) {
+ $statusIcons .= $this->renderStatusIcon('lock', 'wcf.user.status.banned', $row);
+ }
+ if ($row->pendingActivation()) {
+ $statusIcons .= $this->renderStatusIcon('power-off', 'wcf.user.status.isDisabled', $row);
+
+ if ($row->getBlacklistMatches() !== []) {
+ $statusIcons .= $this->renderStatusIcon('warning', 'wcf.user.status.blacklistMatches', $row);
+ }
+ }
+ if ($statusIcons !== '') {
+ $html .= ' ' . $statusIcons . ' ';
+ }
+
+ if (\MODULE_USER_RANK !== 0) {
+ $rank = $row->getRank();
+ if ($row->getUserTitle() !== '') {
+ $html .= \sprintf(
+ ' %s ',
+ $rank !== null && $rank->cssClassName !== ''
+ ? ' ' . StringUtil::encodeHTML($rank->cssClassName)
+ : '',
+ StringUtil::encodeHTML($row->getUserTitle())
+ );
+ }
+ if ($rank !== null && $rank->rankImage !== '') {
+ $html .= ' ' . $rank->getImage() . ' ';
+ }
+ }
+
+ return $html;
+ }
+
+ private function renderStatusIcon(string $icon, string $languageItem, UserProfile $user): string
+ {
+ return \sprintf(
+ '%s ',
+ StringUtil::encodeHTML(WCF::getLanguage()->getDynamicVariable($languageItem, ['user' => $user])),
+ FontAwesomeIcon::fromValues($icon)->toHtml()
+ );
+ }
+ };
+ }
+
+ private function getLastActivityTimeRenderer(): TimeColumnRenderer
+ {
+ return new class extends TimeColumnRenderer {
+ #[\Override]
+ public function render(mixed $value, DatabaseObject $row): string
+ {
+ \assert($row instanceof UserProfile);
+
+ if ($row->isOnline()) {
+ return WCF::getLanguage()->get('wcf.user.online')
+ . ' ';
+ }
+
+ return parent::render($value, $row);
+ }
+ };
+ }
+
+ private function getGroupFilter(): SelectFilter
+ {
+ $options = [];
+ foreach (UserGroup::getSortedAccessibleGroups([], [UserGroup::EVERYONE, UserGroup::GUESTS]) as $group) {
+ $options[$group->groupID] = $group->getName();
+ }
+
+ return new class($options, 'groupID', 'wcf.user.group', '', false) extends SelectFilter {
+ #[\Override]
+ public function applyFilter(DatabaseObjectList $list, string $value): void
+ {
+ if (!isset($this->options[$value])) {
+ throw new InvalidFilterValue(
+ "Invalid value '{$value}' for filter '{$this->id}' given."
+ );
+ }
+
+ $list->getConditionBuilder()->add(
+ "user_table.userID IN (
+ SELECT userID
+ FROM wcf1_user_to_group
+ WHERE groupID = ?
+ )",
+ [(int)$value]
+ );
+ }
+
+ #[\Override]
+ public function renderValue(string $value): string
+ {
+ return $this->options[$value] ?? '';
+ }
+ };
+ }
+
+ private function getIsDisabledFilter(): BooleanFilter
+ {
+ return new class('isDisabled', 'wcf.acp.user.filter.isDisabled') extends BooleanFilter {
+ #[\Override]
+ public function applyFilter(DatabaseObjectList $list, string $value): void
+ {
+ $list->getConditionBuilder()->add("user_table.activationCode <> ?", [0]);
+ }
+ };
+ }
+
+ /**
+ * Matches the users that are awaiting the approval by an administrator,
+ * see `UsersAwaitingApprovalAcpDashboardBox`.
+ */
+ private function getPendingActivationFilter(): BooleanFilter
+ {
+ return new class('pendingActivation', 'wcf.acp.user.filter.pendingActivation') extends BooleanFilter {
+ #[\Override]
+ public function applyFilter(DatabaseObjectList $list, string $value): void
+ {
+ $list->getConditionBuilder()->add("user_table.banned = ?", [0]);
+ $list->getConditionBuilder()->add("user_table.activationCode <> ?", [0]);
+ if (((int)\REGISTER_ACTIVATION_METHOD & User::REGISTER_ACTIVATION_USER) !== 0) {
+ $list->getConditionBuilder()->add("user_table.emailConfirmed IS NULL");
+ }
+ }
+ };
+ }
+
+ private function getEmailConfirmedFilter(): SelectFilter
+ {
+ return new class(
+ [
+ 1 => 'wcf.acp.user.filter.emailConfirmed.yes',
+ 0 => 'wcf.acp.user.filter.emailConfirmed.no',
+ ],
+ 'emailConfirmed',
+ 'wcf.acp.user.filter.emailConfirmed'
+ ) extends SelectFilter {
+ #[\Override]
+ public function applyFilter(DatabaseObjectList $list, string $value): void
+ {
+ if (!isset($this->options[$value])) {
+ throw new InvalidFilterValue(
+ "Invalid value '{$value}' for filter '{$this->id}' given."
+ );
+ }
+
+ if ($value === '1') {
+ $list->getConditionBuilder()->add("user_table.emailConfirmed IS NULL");
+ } else {
+ $list->getConditionBuilder()->add("user_table.emailConfirmed IS NOT NULL");
+ }
+ }
+ };
+ }
+
+ /**
+ * Returns the filters for the profile options. Unlike the members list,
+ * administrators can filter by every option regardless of its visibility.
+ *
+ * @return list
+ */
+ private function getUserOptionFilters(): array
+ {
+ $options = $this->getUserOptionsByCategory('profile', static function (UserOption $option) {
+ return $option->validateOptions()
+ && $option->validatePermissions()
+ && $option->getFormOption()->isFilterable();
+ });
+
+ return UserOptionFilter::createFilters(
+ \array_merge(...\array_values($options)),
+ respectBirthdayYearPrivacy: false
+ );
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/gridView/admin/UserGroupGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/UserGroupGridView.class.php
index de0905774a3..3e228ec1dcd 100644
--- a/wcfsetup/install/files/lib/system/gridView/admin/UserGroupGridView.class.php
+++ b/wcfsetup/install/files/lib/system/gridView/admin/UserGroupGridView.class.php
@@ -3,7 +3,7 @@
namespace wcf\system\gridView\admin;
use wcf\acp\form\UserGroupEditForm;
-use wcf\acp\form\UserSearchForm;
+use wcf\acp\page\UserListPage;
use wcf\data\DatabaseObject;
use wcf\data\user\group\I18nUserGroupList;
use wcf\data\user\group\UserGroup;
@@ -22,6 +22,7 @@
use wcf\system\view\filter\I18nTextFilter;
use wcf\system\view\filter\IntegerFilter;
use wcf\system\WCF;
+use wcf\util\StringUtil;
/**
* Grid view for the list of user groups.
@@ -78,17 +79,19 @@ public function render(mixed $value, DatabaseObject $row): string
{
\assert($row instanceof UserGroup);
- if ($row->groupType === 1 || $row->groupType === 2) {
+ // The user list can only filter by accessible groups other than
+ // the "Everyone" and the "Guests" group.
+ if ($row->groupType === 1 || $row->groupType === 2 || !$row->isAccessible()) {
return parent::render($value, $row);
}
return \sprintf(
'%s ',
WCF::getLanguage()->get("wcf.acp.group.showMembers"),
- LinkHandler::getInstance()->getControllerLink(
- UserSearchForm::class,
- ["groupID" => $row->groupID]
- ),
+ StringUtil::encodeHTML(LinkHandler::getInstance()->getControllerLink(
+ UserListPage::class,
+ ["filters" => ["groupID" => $row->groupID]]
+ )),
parent::render($value, $row)
);
}
diff --git a/wcfsetup/install/files/lib/system/interaction/admin/UserContentRemoveInteraction.class.php b/wcfsetup/install/files/lib/system/interaction/admin/UserContentRemoveInteraction.class.php
new file mode 100644
index 00000000000..10f298a3e0e
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/interaction/admin/UserContentRemoveInteraction.class.php
@@ -0,0 +1,46 @@
+
+ * @since 6.3
+ */
+final class UserContentRemoveInteraction extends FormBuilderDialogInteraction
+{
+ public function __construct()
+ {
+ parent::__construct(
+ 'remove-content',
+ LinkHandler::getInstance()->getControllerLink(UserContentRemoveAction::class, ['id' => '%s']),
+ 'wcf.acp.content.removeContent',
+ static fn(UserProfile $user) => UserAdministrationPolicy::canDelete($user->getDecoratedObject())
+ );
+ }
+
+ #[\Override]
+ public function renderInitialization(string $containerId): string
+ {
+ $identifier = StringUtil::encodeJS($this->getIdentifier());
+ $containerId = StringUtil::encodeJS($containerId);
+
+ return <<
+ require(['WoltLabSuite/Core/Acp/Component/User/RemoveContent'], ({ setup }) => {
+ setup('{$identifier}', document.getElementById('{$containerId}'));
+ });
+
+ HTML;
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/interaction/admin/UserInteractions.class.php b/wcfsetup/install/files/lib/system/interaction/admin/UserInteractions.class.php
new file mode 100644
index 00000000000..6597084b266
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/interaction/admin/UserInteractions.class.php
@@ -0,0 +1,136 @@
+
+ * @since 6.3
+ */
+final class UserInteractions extends AbstractInteractionProvider
+{
+ public function __construct()
+ {
+ $this->addInteractions([
+ new RpcInteraction(
+ 'enable',
+ 'core/users/%s/enable',
+ 'wcf.acp.user.enable',
+ isAvailableCallback: static fn(UserProfile $user) => $user->pendingActivation()
+ && UserAdministrationPolicy::canEnable($user->getDecoratedObject()),
+ interactionEffect: InteractionEffect::ReloadPage
+ ),
+ new RpcInteraction(
+ 'disable',
+ 'core/users/%s/disable',
+ 'wcf.acp.user.disable',
+ isAvailableCallback: static fn(UserProfile $user) => !$user->pendingActivation()
+ && UserAdministrationPolicy::canEnable($user->getDecoratedObject()),
+ interactionEffect: InteractionEffect::ReloadPage
+ ),
+ new RpcInteraction(
+ 'confirm-email',
+ 'core/users/%s/confirm-email',
+ 'wcf.acp.user.action.confirmEmail',
+ isAvailableCallback: static fn(UserProfile $user) => !$user->isEmailConfirmed()
+ && UserAdministrationPolicy::canEnable($user->getDecoratedObject()),
+ interactionEffect: InteractionEffect::ReloadPage
+ ),
+ new RpcInteraction(
+ 'unconfirm-email',
+ 'core/users/%s/unconfirm-email',
+ 'wcf.acp.user.action.unconfirmEmail',
+ isAvailableCallback: static fn(UserProfile $user) => $user->isEmailConfirmed()
+ && UserAdministrationPolicy::canEnable($user->getDecoratedObject()),
+ interactionEffect: InteractionEffect::ReloadPage
+ ),
+ new RpcInteraction(
+ 'resend-activation-mail',
+ 'core/users/%s/resend-activation-mail',
+ 'wcf.acp.user.action.resendActivationMail',
+ isAvailableCallback: static fn(UserProfile $user) => UserAdministrationPolicy::canResendActivationMail(
+ $user->getDecoratedObject()
+ ),
+ interactionEffect: InteractionEffect::ReloadPage
+ ),
+ new FormBuilderDialogInteraction(
+ 'ban',
+ LinkHandler::getInstance()->getControllerLink(UserBanAction::class, ['id' => '%s']),
+ 'wcf.acp.user.ban',
+ static fn(UserProfile $user) => $user->banned === 0
+ && UserAdministrationPolicy::canBan($user->getDecoratedObject()),
+ InteractionEffect::ReloadPage
+ ),
+ new RpcInteraction(
+ 'unban',
+ 'core/users/%s/unban',
+ 'wcf.acp.user.unban',
+ isAvailableCallback: static fn(UserProfile $user) => $user->banned === 1
+ && UserAdministrationPolicy::canBan($user->getDecoratedObject()),
+ interactionEffect: InteractionEffect::ReloadPage
+ ),
+ new LinkInteraction(
+ 'send-mail',
+ UserMailForm::class,
+ 'wcf.acp.user.action.sendMail',
+ static fn(UserProfile $user) => UserAdministrationPolicy::canMail($user->getDecoratedObject())
+ ),
+ new RpcInteraction(
+ 'send-new-password',
+ 'core/users/%s/send-new-password',
+ 'wcf.acp.user.action.sendNewPassword',
+ InteractionConfirmationType::Custom,
+ 'wcf.acp.user.action.sendNewPassword.confirmMessage',
+ static fn(UserProfile $user) => UserAdministrationPolicy::canSendNewPassword(
+ $user->getDecoratedObject()
+ )
+ ),
+ new LinkInteraction(
+ 'export-gdpr',
+ UserExportGdprAction::class,
+ 'wcf.acp.user.exportGdpr',
+ static fn(UserProfile $user) => UserAdministrationPolicy::canExportGdpr($user->getDecoratedObject())
+ ),
+ new Divider(),
+ new DeleteInteraction(
+ 'core/users/%s',
+ static fn(UserProfile $user) => UserAdministrationPolicy::canDelete($user->getDecoratedObject())
+ ),
+ new UserContentRemoveInteraction(),
+ ]);
+
+ EventHandler::getInstance()->fire(
+ new UserInteractionCollecting($this)
+ );
+ }
+
+ #[\Override]
+ public function getObjectClassName(): string
+ {
+ return UserProfile::class;
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/interaction/bulk/BulkLinkInteraction.class.php b/wcfsetup/install/files/lib/system/interaction/bulk/BulkLinkInteraction.class.php
new file mode 100644
index 00000000000..642b3ce2510
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/interaction/bulk/BulkLinkInteraction.class.php
@@ -0,0 +1,50 @@
+
+ * @since 6.3
+ */
+class BulkLinkInteraction extends AbstractBulkInteraction
+{
+ /**
+ * @param array $parameters additional parameters of the link
+ */
+ public function __construct(
+ string $identifier,
+ protected readonly string $controllerClass,
+ protected readonly string $languageItem,
+ ?\Closure $isAvailableCallback = null,
+ protected readonly array $parameters = [],
+ ) {
+ parent::__construct($identifier, $isAvailableCallback);
+ }
+
+ #[\Override]
+ public function render(array $objects): string
+ {
+ $objectIDs = \array_values(\array_map(fn(DatabaseObject $object) => $object->getObjectID(), $objects));
+
+ return \sprintf(
+ '%s ',
+ StringUtil::encodeHTML(
+ LinkHandler::getInstance()->getControllerLink($this->controllerClass, \array_merge(
+ $this->parameters,
+ ['ids' => $objectIDs]
+ ))
+ ),
+ WCF::getLanguage()->get($this->languageItem)
+ );
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserBulkInteractions.class.php b/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserBulkInteractions.class.php
new file mode 100644
index 00000000000..7f173bbe06b
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserBulkInteractions.class.php
@@ -0,0 +1,138 @@
+
+ * @since 6.3
+ */
+final class UserBulkInteractions extends AbstractBulkInteractionProvider
+{
+ public function __construct()
+ {
+ $this->addInteractions([
+ new BulkRpcInteraction(
+ 'enable',
+ 'core/users/%s/enable',
+ 'wcf.acp.user.enable',
+ isAvailableCallback: static fn(UserProfile $user) => $user->pendingActivation()
+ && UserAdministrationPolicy::canEnable($user->getDecoratedObject())
+ ),
+ new BulkRpcInteraction(
+ 'confirm-email',
+ 'core/users/%s/confirm-email',
+ 'wcf.acp.user.action.confirmEmail',
+ isAvailableCallback: static fn(UserProfile $user) => !$user->isEmailConfirmed()
+ && UserAdministrationPolicy::canEnable($user->getDecoratedObject())
+ ),
+ new BulkRpcInteraction(
+ 'unconfirm-email',
+ 'core/users/%s/unconfirm-email',
+ 'wcf.acp.user.action.unconfirmEmail',
+ isAvailableCallback: static fn(UserProfile $user) => $user->isEmailConfirmed()
+ && UserAdministrationPolicy::canEnable($user->getDecoratedObject())
+ ),
+ new BulkRpcInteraction(
+ 'resend-activation-mail',
+ 'core/users/%s/resend-activation-mail',
+ 'wcf.acp.user.action.resendActivationMail',
+ isAvailableCallback: static fn(UserProfile $user) => UserAdministrationPolicy::canResendActivationMail(
+ $user->getDecoratedObject()
+ )
+ ),
+ new BulkFormBuilderDialogInteraction(
+ 'ban',
+ UserBanAction::class,
+ 'wcf.acp.user.ban',
+ static fn(UserProfile $user) => $user->banned === 0
+ && UserAdministrationPolicy::canBan($user->getDecoratedObject())
+ ),
+ new BulkRpcInteraction(
+ 'unban',
+ 'core/users/%s/unban',
+ 'wcf.acp.user.unban',
+ isAvailableCallback: static fn(UserProfile $user) => $user->banned === 1
+ && UserAdministrationPolicy::canBan($user->getDecoratedObject())
+ ),
+ new BulkLinkInteraction(
+ 'assign-to-group',
+ UserAssignToGroupForm::class,
+ 'wcf.acp.user.action.assignToGroup',
+ static fn(UserProfile $user) => UserAdministrationPolicy::canEdit($user->getDecoratedObject())
+ ),
+ new BulkLinkInteraction(
+ 'merge',
+ UserMergeForm::class,
+ 'wcf.acp.user.action.merge',
+ static fn(UserProfile $user) => UserAdministrationPolicy::canEdit($user->getDecoratedObject())
+ ),
+ new BulkLinkInteraction(
+ 'send-mail',
+ UserMailForm::class,
+ 'wcf.acp.user.action.sendMail',
+ static fn(UserProfile $user) => UserAdministrationPolicy::canMail($user->getDecoratedObject())
+ ),
+ new BulkLinkInteraction(
+ 'export-mail-address',
+ UserEmailAddressExportForm::class,
+ 'wcf.acp.user.action.exportMailAddress',
+ static fn() => WCF::getSession()->hasPermission('admin.user.canEditMailAddress')
+ ),
+ new BulkRpcInteraction(
+ 'send-new-password',
+ 'core/users/%s/send-new-password',
+ 'wcf.acp.user.action.sendNewPassword',
+ InteractionConfirmationType::Custom,
+ 'wcf.acp.user.action.sendNewPassword.confirmMessage.bulk',
+ static fn(UserProfile $user) => UserAdministrationPolicy::canSendNewPassword(
+ $user->getDecoratedObject()
+ )
+ ),
+ new BulkLinkInteraction(
+ 'revert-content-changes',
+ UserContentRevertChangesForm::class,
+ 'wcf.acp.user.action.revertContentChanges',
+ static fn() => \MODULE_EDIT_HISTORY !== 0
+ && WCF::getSession()->hasPermission('admin.content.canBulkRevertContentChanges')
+ ),
+ new BulkDeleteInteraction(
+ 'core/users/%s',
+ static fn(UserProfile $user) => UserAdministrationPolicy::canDelete($user->getDecoratedObject())
+ ),
+ new UserContentRemoveBulkInteraction(),
+ ]);
+
+ EventHandler::getInstance()->fire(
+ new UserBulkInteractionCollecting($this)
+ );
+ }
+
+ #[\Override]
+ public function getObjectListClassName(): string
+ {
+ return UserProfileList::class;
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserContentRemoveBulkInteraction.class.php b/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserContentRemoveBulkInteraction.class.php
new file mode 100644
index 00000000000..f084b3e0ff5
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserContentRemoveBulkInteraction.class.php
@@ -0,0 +1,45 @@
+
+ * @since 6.3
+ */
+final class UserContentRemoveBulkInteraction extends BulkFormBuilderDialogInteraction
+{
+ public function __construct()
+ {
+ parent::__construct(
+ 'remove-content',
+ UserContentRemoveAction::class,
+ 'wcf.acp.content.removeContent',
+ static fn(UserProfile $user) => UserAdministrationPolicy::canDelete($user->getDecoratedObject())
+ );
+ }
+
+ #[\Override]
+ public function renderInitialization(string $containerId): string
+ {
+ $identifier = StringUtil::encodeJS($this->getIdentifier());
+ $containerId = StringUtil::encodeJS($containerId);
+
+ return <<
+ require(['WoltLabSuite/Core/Acp/Component/User/RemoveContent'], ({ setupBulk }) => {
+ setupBulk('{$identifier}', document.getElementById('{$containerId}'));
+ });
+
+ HTML;
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/listView/user/MembersListView.class.php b/wcfsetup/install/files/lib/system/listView/user/MembersListView.class.php
index 7c297cdc6cd..852690e7ed3 100644
--- a/wcfsetup/install/files/lib/system/listView/user/MembersListView.class.php
+++ b/wcfsetup/install/files/lib/system/listView/user/MembersListView.class.php
@@ -123,23 +123,10 @@ protected function getUserOptionFilters(): array
return false;
}
- if (!$option->getFormOption()->isFilterable()) {
- return false;
- }
-
- // Date options store `Y-m-d` instead of the timestamps expected by the
- // date range filter. The birthday is filtered by the age instead, which
- // respects users who hide the year of their birth.
- return $option->optionType !== 'date';
+ return $option->getFormOption()->isFilterable();
});
- return \array_map(
- static fn(UserOption $option) => match ($option->optionType) {
- 'birthday' => new UserAgeFilter($option),
- default => new UserOptionFilter($option),
- },
- \array_merge(...\array_values($options))
- );
+ return UserOptionFilter::createFilters(\array_merge(...\array_values($options)));
}
protected function getFirstLetterFilter(): SelectFilter
diff --git a/wcfsetup/install/files/lib/system/user/UserAdministrationPolicy.class.php b/wcfsetup/install/files/lib/system/user/UserAdministrationPolicy.class.php
new file mode 100644
index 00000000000..6ce92a27653
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/user/UserAdministrationPolicy.class.php
@@ -0,0 +1,144 @@
+
+ * @since 6.3
+ */
+final class UserAdministrationPolicy
+{
+ /**
+ * Returns true if all groups of the user are accessible to the active user
+ * and, unless `$allowSelf` is set, the user is not the active user.
+ */
+ public static function isManageable(User $user, bool $allowSelf = false): bool
+ {
+ if (!$allowSelf && $user->userID === WCF::getUser()->userID) {
+ return false;
+ }
+
+ return UserGroup::isAccessibleGroup($user->getGroupIDs());
+ }
+
+ /**
+ * Returns true if the user can be enabled or disabled and if the email
+ * address of the user can be confirmed or unconfirmed.
+ */
+ public static function canEnable(User $user): bool
+ {
+ return WCF::getSession()->hasPermission('admin.user.canEnableUser')
+ && self::isManageable($user);
+ }
+
+ public static function canBan(User $user): bool
+ {
+ return WCF::getSession()->hasPermission('admin.user.canBanUser')
+ && self::isManageable($user);
+ }
+
+ /**
+ * Returns true if a link to set a new password can be sent to the user,
+ * which is not possible for users that log in via a third party provider.
+ */
+ public static function canSendNewPassword(User $user): bool
+ {
+ return WCF::getSession()->hasPermission('admin.user.canEditPassword')
+ && $user->authData === ''
+ && self::isManageable($user);
+ }
+
+ public static function canResendActivationMail(User $user): bool
+ {
+ return WCF::getSession()->hasPermission('admin.user.canEnableUser')
+ && $user->canEmailConfirm()
+ && self::isManageable($user);
+ }
+
+ /**
+ * Returns true if the user and its content can be deleted.
+ */
+ public static function canDelete(User $user): bool
+ {
+ return WCF::getSession()->hasPermission('admin.user.canDeleteUser')
+ && self::isManageable($user);
+ }
+
+ public static function canExportGdpr(User $user): bool
+ {
+ return WCF::getSession()->hasPermission('admin.user.canExportGdprData')
+ && self::isManageable($user, true);
+ }
+
+ public static function canMail(User $user): bool
+ {
+ return WCF::getSession()->hasPermission('admin.user.canMailUser')
+ && $user->userID !== WCF::getUser()->userID;
+ }
+
+ public static function canEdit(User $user): bool
+ {
+ return $user->canEdit();
+ }
+
+ /**
+ * @throws PermissionDeniedException
+ */
+ public static function assertCanEnable(User $user): void
+ {
+ if (!self::canEnable($user)) {
+ throw new PermissionDeniedException();
+ }
+ }
+
+ /**
+ * @throws PermissionDeniedException
+ */
+ public static function assertCanBan(User $user): void
+ {
+ if (!self::canBan($user)) {
+ throw new PermissionDeniedException();
+ }
+ }
+
+ /**
+ * @throws PermissionDeniedException
+ */
+ public static function assertCanSendNewPassword(User $user): void
+ {
+ if (!self::canSendNewPassword($user)) {
+ throw new PermissionDeniedException();
+ }
+ }
+
+ /**
+ * @throws PermissionDeniedException
+ */
+ public static function assertCanResendActivationMail(User $user): void
+ {
+ if (!self::canResendActivationMail($user)) {
+ throw new PermissionDeniedException();
+ }
+ }
+
+ /**
+ * @throws PermissionDeniedException
+ */
+ public static function assertCanDelete(User $user): void
+ {
+ if (!self::canDelete($user)) {
+ throw new PermissionDeniedException();
+ }
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/view/filter/UserAgeFilter.class.php b/wcfsetup/install/files/lib/system/view/filter/UserAgeFilter.class.php
index 94203543c8f..3e96d96d420 100644
--- a/wcfsetup/install/files/lib/system/view/filter/UserAgeFilter.class.php
+++ b/wcfsetup/install/files/lib/system/view/filter/UserAgeFilter.class.php
@@ -12,7 +12,8 @@
/**
* Filters users by their age that is derived from the birthday user option,
* requires a list of users. Users who hide the year of their birthday are
- * never matched.
+ * never matched unless `$respectShowYear` is disabled, which is reserved for
+ * administrative views.
*
* @author Marcel Werk
* @copyright 2001-2026 WoltLab GmbH
@@ -23,8 +24,10 @@ final class UserAgeFilter extends IntegerFilter
{
private const MAXIMUM_AGE = 120;
- public function __construct(UserOption $birthdayOption)
- {
+ public function __construct(
+ UserOption $birthdayOption,
+ private readonly bool $respectShowYear = true,
+ ) {
parent::__construct(
'age',
'wcf.user.birthday.age',
@@ -54,7 +57,7 @@ public function applyFilter(DatabaseObjectList $list, string $value): void
$this->joinUserOptionValues($list);
$showYearOptionID = User::getUserOptionID('birthdayShowYear');
- if ($showYearOptionID !== null) {
+ if ($this->respectShowYear && $showYearOptionID !== null) {
$list->getConditionBuilder()->add("user_option_value.userOption{$showYearOptionID} = ?", [1]);
}
diff --git a/wcfsetup/install/files/lib/system/view/filter/UserOptionFilter.class.php b/wcfsetup/install/files/lib/system/view/filter/UserOptionFilter.class.php
index 639ccf9c563..1da73e978cf 100644
--- a/wcfsetup/install/files/lib/system/view/filter/UserOptionFilter.class.php
+++ b/wcfsetup/install/files/lib/system/view/filter/UserOptionFilter.class.php
@@ -61,6 +61,33 @@ public function unserializeValue(string $value): mixed
return $this->option->getFormOption()->unserializeValue($value);
}
+ /**
+ * Creates the filters for the given user options. The birthday is filtered
+ * by the age, date options are skipped because they store `Y-m-d` instead
+ * of the timestamps expected by the date range filter.
+ *
+ * @param UserOption[] $options
+ * @param bool $respectBirthdayYearPrivacy skips users who hide the year of their birthday when filtering by age
+ * @return list
+ */
+ public static function createFilters(array $options, bool $respectBirthdayYearPrivacy = true): array
+ {
+ $filters = [];
+ foreach ($options as $option) {
+ $filter = match ($option->optionType) {
+ 'birthday' => new UserAgeFilter($option, $respectBirthdayYearPrivacy),
+ 'date' => null,
+ default => new self($option),
+ };
+
+ if ($filter !== null) {
+ $filters[] = $filter;
+ }
+ }
+
+ return $filters;
+ }
+
/**
* Joins the user option values when counting the objects or reading their
* ids, `UserList` only joins them when reading the objects.
diff --git a/wcfsetup/install/files/lib/system/worker/SendNewPasswordWorker.class.php b/wcfsetup/install/files/lib/system/worker/SendNewPasswordWorker.class.php
index 24418d5e37a..2ddd360af94 100644
--- a/wcfsetup/install/files/lib/system/worker/SendNewPasswordWorker.class.php
+++ b/wcfsetup/install/files/lib/system/worker/SendNewPasswordWorker.class.php
@@ -2,19 +2,11 @@
namespace wcf\system\worker;
-use ParagonIE\ConstantTime\Hex;
use wcf\acp\page\UserListPage;
+use wcf\command\user\SendNewPassword;
use wcf\data\user\group\UserGroup;
-use wcf\data\user\User;
-use wcf\data\user\UserAction;
-use wcf\data\user\UserEditor;
use wcf\data\user\UserList;
-use wcf\system\background\BackgroundQueueHandler;
use wcf\system\clipboard\ClipboardHandler;
-use wcf\system\email\Email;
-use wcf\system\email\mime\MimePartFacade;
-use wcf\system\email\mime\RecipientAwareTextMimePart;
-use wcf\system\email\UserMailbox;
use wcf\system\exception\PermissionDeniedException;
use wcf\system\exception\SystemException;
use wcf\system\request\LinkHandler;
@@ -26,6 +18,7 @@
* @author Matthias Schmidt
* @copyright 2001-2019 WoltLab GmbH
* @license GNU Lesser General Public License
+ * @deprecated 6.3 Use the `SendNewPassword` command or the `core/users/{id}/send-new-password` endpoint.
*/
class SendNewPasswordWorker extends AbstractWorker
{
@@ -34,11 +27,6 @@ class SendNewPasswordWorker extends AbstractWorker
*/
protected $limit = 20;
- /**
- * @var array
- */
- private array $lostPasswordKeys = [];
-
#[\Override]
public function countObjects()
{
@@ -51,28 +39,14 @@ public function countObjects()
#[\Override]
public function execute()
{
- $userList = new UserList();
- $userList->decoratorClassName = UserEditor::class;
- $userList->getConditionBuilder()->add('user_table.userID IN (?)', [$this->parameters['userIDs']]);
- $userList->sqlLimit = $this->limit;
- $userList->sqlOffset = $this->limit * $this->loopCount;
- $userList->readObjects();
-
- /** @var UserEditor $userEditor */
- // @phpstan-ignore varTag.nativeType
- foreach ($userList as $userEditor) {
- $this->resetPassword($userEditor);
- }
-
$userList = new UserList();
$userList->getConditionBuilder()->add('user_table.userID IN (?)', [$this->parameters['userIDs']]);
$userList->sqlLimit = $this->limit;
$userList->sqlOffset = $this->limit * $this->loopCount;
$userList->readObjects();
- /** @var User $user */
foreach ($userList as $user) {
- $this->sendLink($user);
+ (new SendNewPassword($user))();
}
}
@@ -98,56 +72,6 @@ public function getProgress()
return $progress;
}
- /**
- * Resets the password of the given user.
- *
- * @param UserEditor $userEditor
- * @return void
- */
- protected function resetPassword(UserEditor $userEditor)
- {
- $lostPasswordKey = Hex::encode(\random_bytes(20));
- $lastLostPasswordRequestTime = \TIME_NOW;
- $userAction = new UserAction([$userEditor], 'update', [
- 'data' => [
- 'password' => null,
- 'lostPasswordKey' => \hash('sha256', $lostPasswordKey),
- 'lastLostPasswordRequestTime' => $lastLostPasswordRequestTime,
- ],
- ]);
- $userAction->executeAction();
-
- $this->lostPasswordKeys[$userEditor->userID] = $lostPasswordKey;
- }
-
- /**
- * Send links.
- *
- * @param User $user
- * @return void
- */
- protected function sendLink(User $user)
- {
- $email = new Email();
- $email->setMessageID(\sprintf(
- 'com.woltlab.wcf.sendNewPassword/%d/%d/%s',
- $user->userID,
- \TIME_NOW,
- \bin2hex(\random_bytes(8))
- ));
- $email->addRecipient(new UserMailbox($user));
- $email->setSubject($user->getLanguage()->getDynamicVariable('wcf.acp.user.sendNewPassword.mail.subject'));
- $lostPasswordKey = $this->lostPasswordKeys[$user->userID] ?? '';
- $email->setBody(new MimePartFacade([
- new RecipientAwareTextMimePart('text/html', 'email_sendNewPassword', 'wcf', ['lostPasswordKey' => $lostPasswordKey]),
- new RecipientAwareTextMimePart('text/plain', 'email_sendNewPassword', 'wcf', ['lostPasswordKey' => $lostPasswordKey]),
- ]));
- $jobs = $email->getJobs();
- foreach ($jobs as $job) {
- BackgroundQueueHandler::getInstance()->performJob($job);
- }
- }
-
#[\Override]
public function validate()
{
diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml
index 6626e7ba1a9..b2716969747 100644
--- a/wcfsetup/install/lang/de.xml
+++ b/wcfsetup/install/lang/de.xml
@@ -1132,7 +1132,6 @@ Sie erreichen das Fehlerprotokoll unter: {link controller='ExceptionLogView' isE
-
@@ -3059,7 +3058,6 @@ Abschnitte dürfen nicht leer sein und nur folgende Zeichen enthalten: [a-z
-
@@ -3085,15 +3083,6 @@ Abschnitte dürfen nicht leer sein und nur folgende Zeichen enthalten: [a-z
-
-
-
-
-
-
-
-
-
@@ -3148,10 +3137,6 @@ Abschnitte dürfen nicht leer sein und nur folgende Zeichen enthalten: [a-z
- Benutzerprofilfeld-Kategorie hinzufügen.]]>
-
-
-
-
@@ -3196,8 +3181,6 @@ Abschnitte dürfen nicht leer sein und nur folgende Zeichen enthalten: [a-z
-
-
- getUser()->username},
@@ -3259,6 +3242,20 @@ freigeschaltet. {if LANGUAGE_USE_INFORMAL_VARIANT}Du kannst{else}Sie können{/if
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -3435,21 +3432,6 @@ Erlaubte Dateiendungen: {', '|implode:$attachmentHandler->getFormattedAllowedExt
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -5798,5 +5780,37 @@ Erlaubte Dateiendungen: {', '|implode:$allowedFileExtensions}]]>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml
index d06b83af2a0..9a169a993a3 100644
--- a/wcfsetup/install/lang/en.xml
+++ b/wcfsetup/install/lang/en.xml
@@ -1110,7 +1110,6 @@ You can access the error log at: {link controller='ExceptionLogView' isEmail=tru
-
@@ -2988,7 +2987,6 @@ If you have already bought the licenses for the listed apps , th
-
@@ -3014,15 +3012,6 @@ If you have already bought the licenses for the listed apps , th
-
-
-
-
-
-
-
-
-
@@ -3077,10 +3066,6 @@ If you have already bought the licenses for the listed apps , th
- add a user field category before creating custom user fields.]]>
-
-
-
-
@@ -3125,8 +3110,6 @@ If you have already bought the licenses for the listed apps , th
-
-
- getUser()->username},
@@ -3184,6 +3167,20 @@ Your account on {@PAGE_TITLE|phrase} [URL:{link isEmail=true}{/link}] has been a
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -3360,21 +3357,6 @@ Allowed extensions: {', '|implode:$attachmentHandler->getFormattedAllowedExtensi
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -5800,5 +5782,37 @@ Allowed extensions: {', '|implode:$allowedFileExtensions}]]>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
From f349eb94b428cac33c0ac9eaccdec17180c976c0 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Sun, 4 Oct 2026 22:31:01 +0200
Subject: [PATCH 017/115] Sort ACP user list by newest registrations by default
---
wcfsetup/install/files/acp/templates/userList.tpl | 5 ++---
.../acp/templates/usersAwaitingApprovalAcpDashboardBox.tpl | 2 +-
.../files/lib/system/gridView/admin/UserGridView.class.php | 3 ++-
wcfsetup/install/lang/de.xml | 2 +-
wcfsetup/install/lang/en.xml | 2 +-
5 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/wcfsetup/install/files/acp/templates/userList.tpl b/wcfsetup/install/files/acp/templates/userList.tpl
index 029b9dc737c..441ba65ac91 100644
--- a/wcfsetup/install/files/acp/templates/userList.tpl
+++ b/wcfsetup/install/files/acp/templates/userList.tpl
@@ -17,9 +17,8 @@
{icon name='magnifying-glass'} {lang}wcf.acp.user.quickSearch{/lang}
diff --git a/wcfsetup/install/files/acp/templates/usersAwaitingApprovalAcpDashboardBox.tpl b/wcfsetup/install/files/acp/templates/usersAwaitingApprovalAcpDashboardBox.tpl
index b24f5b75ebb..9077bbeeb8d 100644
--- a/wcfsetup/install/files/acp/templates/usersAwaitingApprovalAcpDashboardBox.tpl
+++ b/wcfsetup/install/files/acp/templates/usersAwaitingApprovalAcpDashboardBox.tpl
@@ -21,7 +21,7 @@
diff --git a/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php
index 7f1fc75d8f6..770ced49cf9 100644
--- a/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php
+++ b/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php
@@ -146,7 +146,8 @@ public function __construct()
)
);
- $this->setDefaultSortField('username');
+ $this->setDefaultSortField('registrationDate');
+ $this->setDefaultSortOrder('DESC');
$this->setRowsPerPage(50);
}
diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml
index b2716969747..8188af69c18 100644
--- a/wcfsetup/install/lang/de.xml
+++ b/wcfsetup/install/lang/de.xml
@@ -3094,7 +3094,6 @@ Abschnitte dürfen nicht leer sein und nur folgende Zeichen enthalten: [a-z
- {literal}{$username}{/literal}
sowie {literal}{$email}{/literal} können als Platzhalter verwendet werden, um den Benutzernamen bzw. die E-Mail-Adresse des jeweiligen Empfängers einzufügen.]]>
-
@@ -5812,5 +5811,6 @@ Erlaubte Dateiendungen: {', '|implode:$allowedFileExtensions}]]>
+
diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml
index 9a169a993a3..e0f4d38ceb0 100644
--- a/wcfsetup/install/lang/en.xml
+++ b/wcfsetup/install/lang/en.xml
@@ -3023,7 +3023,6 @@ If you have already bought the licenses for the listed apps , th
- {literal}{$username}{/literal} and
{literal}{$email}{/literal} can be used as placeholders to insert the username or email address of the respective recipient.]]>
-
@@ -5814,5 +5813,6 @@ Allowed extensions: {', '|implode:$allowedFileExtensions}]]>
+
From e5e78c162cad08973c1db001409fc5ba358b6367 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Sun, 4 Oct 2026 22:52:00 +0200
Subject: [PATCH 018/115] Fix ban dialog crash caused by mismatched date save
format
---
.../install/files/lib/acp/action/UserBanAction.class.php | 7 +------
1 file changed, 1 insertion(+), 6 deletions(-)
diff --git a/wcfsetup/install/files/lib/acp/action/UserBanAction.class.php b/wcfsetup/install/files/lib/acp/action/UserBanAction.class.php
index 323d883a5d0..7e2f9ed1423 100644
--- a/wcfsetup/install/files/lib/acp/action/UserBanAction.class.php
+++ b/wcfsetup/install/files/lib/acp/action/UserBanAction.class.php
@@ -48,11 +48,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface
$data = $form->getData()['data'];
$banExpires = 0;
if (!(bool)$data['banNeverExpires']) {
- $banExpires = \DateTimeImmutable::createFromFormat(
- '!Y-m-d',
- $data['banExpires'],
- new \DateTimeZone(\TIMEZONE)
- )->getTimestamp();
+ $banExpires = $data['banExpires'];
}
WCF::getDB()->beginTransaction();
@@ -125,7 +121,6 @@ private function getForm(): Psr15DialogForm
DateFormField::create('banExpires')
->label('wcf.acp.user.ban.expires')
->description('wcf.acp.user.ban.expires.description')
- ->saveValueFormat('Y-m-d')
->earliestDate(\TIME_NOW)
->required()
->addDependency(
From c4ed40a69c45e8430556c8922af518cf6b26f0dc Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Sun, 4 Oct 2026 22:59:15 +0200
Subject: [PATCH 019/115] Use `wcf.user.banned` for ban status tooltip in user
grid view
---
.../files/lib/system/gridView/admin/UserGridView.class.php | 4 ++--
wcfsetup/install/lang/de.xml | 2 +-
wcfsetup/install/lang/en.xml | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php
index 770ced49cf9..9aaa553791e 100644
--- a/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php
+++ b/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php
@@ -224,7 +224,7 @@ public function render(mixed $value, DatabaseObject $row): string
$statusIcons = '';
if ($row->banned !== 0) {
- $statusIcons .= $this->renderStatusIcon('lock', 'wcf.user.status.banned', $row);
+ $statusIcons .= $this->renderStatusIcon('lock', 'wcf.user.banned', $row);
}
if ($row->pendingActivation()) {
$statusIcons .= $this->renderStatusIcon('power-off', 'wcf.user.status.isDisabled', $row);
@@ -260,7 +260,7 @@ private function renderStatusIcon(string $icon, string $languageItem, UserProfil
{
return \sprintf(
'%s ',
- StringUtil::encodeHTML(WCF::getLanguage()->getDynamicVariable($languageItem, ['user' => $user])),
+ WCF::getLanguage()->getDynamicVariable($languageItem, ['user' => $user]),
FontAwesomeIcon::fromValues($icon)->toHtml()
);
}
diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml
index 8188af69c18..b4904fd048f 100644
--- a/wcfsetup/install/lang/de.xml
+++ b/wcfsetup/install/lang/de.xml
@@ -4805,7 +4805,6 @@ sich{/if} nicht bei uns registriert {if LANGUAGE_USE_INFORMAL_VARIANT}hast{else}
-
- getBlacklistMatchesTitle() item=matchLabel}{$matchLabel}{/implode}).]]>
@@ -5812,5 +5811,6 @@ Erlaubte Dateiendungen: {', '|implode:$allowedFileExtensions}]]>
+
diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml
index e0f4d38ceb0..244a44b64fb 100644
--- a/wcfsetup/install/lang/en.xml
+++ b/wcfsetup/install/lang/en.xml
@@ -4801,7 +4801,6 @@ not register with us.]]>
-
- getBlacklistMatchesTitle() item=matchLabel}{$matchLabel}{/implode}).]]>
@@ -5814,5 +5813,6 @@ Allowed extensions: {', '|implode:$allowedFileExtensions}]]>
+
From c0462c76a2f99d851ef648c12143802d29733dd3 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Sun, 4 Oct 2026 23:02:54 +0200
Subject: [PATCH 020/115] Make status icon tooltips in user grid view hoverable
---
.../files/lib/system/gridView/admin/UserGridView.class.php | 2 +-
wcfsetup/install/files/style/ui/gridView.scss | 5 +++++
2 files changed, 6 insertions(+), 1 deletion(-)
diff --git a/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php
index 9aaa553791e..6f8a855a696 100644
--- a/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php
+++ b/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php
@@ -234,7 +234,7 @@ public function render(mixed $value, DatabaseObject $row): string
}
}
if ($statusIcons !== '') {
- $html .= ' ' . $statusIcons . ' ';
+ $html .= ' ' . $statusIcons . ' ';
}
if (\MODULE_USER_RANK !== 0) {
diff --git a/wcfsetup/install/files/style/ui/gridView.scss b/wcfsetup/install/files/style/ui/gridView.scss
index c3962439a1d..b71ece82518 100644
--- a/wcfsetup/install/files/style/ui/gridView.scss
+++ b/wcfsetup/install/files/style/ui/gridView.scss
@@ -359,3 +359,8 @@ html[data-color-scheme="dark"] .gridView__footer__container {
grid-template-columns: 1fr;
}
}
+
+.gridView__statusIcons {
+ position: relative;
+ z-index: 1;
+}
From 454d53c9ee562b544109672b5529504881c08a66 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Sun, 4 Oct 2026 23:07:47 +0200
Subject: [PATCH 021/115] Add "Show Profile" interaction to ACP user actions
Closes #6497
---
.../lib/system/interaction/admin/UserInteractions.class.php | 3 +++
wcfsetup/install/lang/de.xml | 1 +
wcfsetup/install/lang/en.xml | 1 +
3 files changed, 5 insertions(+)
diff --git a/wcfsetup/install/files/lib/system/interaction/admin/UserInteractions.class.php b/wcfsetup/install/files/lib/system/interaction/admin/UserInteractions.class.php
index 6597084b266..13e80eb137f 100644
--- a/wcfsetup/install/files/lib/system/interaction/admin/UserInteractions.class.php
+++ b/wcfsetup/install/files/lib/system/interaction/admin/UserInteractions.class.php
@@ -14,6 +14,7 @@
use wcf\system\interaction\FormBuilderDialogInteraction;
use wcf\system\interaction\InteractionConfirmationType;
use wcf\system\interaction\InteractionEffect;
+use wcf\system\interaction\LinkableObjectInteraction;
use wcf\system\interaction\LinkInteraction;
use wcf\system\interaction\RpcInteraction;
use wcf\system\request\LinkHandler;
@@ -36,6 +37,8 @@ final class UserInteractions extends AbstractInteractionProvider
public function __construct()
{
$this->addInteractions([
+ new LinkableObjectInteraction('view', 'wcf.acp.user.action.viewProfile'),
+ new Divider(),
new RpcInteraction(
'enable',
'core/users/%s/enable',
diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml
index b4904fd048f..15223ce6115 100644
--- a/wcfsetup/install/lang/de.xml
+++ b/wcfsetup/install/lang/de.xml
@@ -3237,6 +3237,7 @@ freigeschaltet. {if LANGUAGE_USE_INFORMAL_VARIANT}Du kannst{else}Sie können{/if
- ohne zusätzliche Sicherheitsabfrage bei allen Benutzern aus, die unter die eingestellten Bedingungen fallen.]]>
+
diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml
index 244a44b64fb..c15daff89f1 100644
--- a/wcfsetup/install/lang/en.xml
+++ b/wcfsetup/install/lang/en.xml
@@ -3162,6 +3162,7 @@ Your account on {@PAGE_TITLE|phrase} [URL:{link isEmail=true}{/link}] has been a
- Attention: The bulk processing executes all actions below on all users matching the selected conditions without any further confirmation prompt!]]>
+
From df2261eb61b833ec504916ff51d1b5ca3db37d58 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Mon, 5 Oct 2026 12:48:43 +0200
Subject: [PATCH 022/115] Extract user merge logic into `MergeUsers` command
---
.../lib/acp/form/UserMergeForm.class.php | 192 +-----------------
.../lib/command/user/MergeUsers.class.php | 122 +++++++++++
.../lib/event/user/UsersMerging.class.php | 28 +++
3 files changed, 155 insertions(+), 187 deletions(-)
create mode 100644 wcfsetup/install/files/lib/command/user/MergeUsers.class.php
create mode 100644 wcfsetup/install/files/lib/event/user/UsersMerging.class.php
diff --git a/wcfsetup/install/files/lib/acp/form/UserMergeForm.class.php b/wcfsetup/install/files/lib/acp/form/UserMergeForm.class.php
index 6de65410737..f6cc9550e83 100644
--- a/wcfsetup/install/files/lib/acp/form/UserMergeForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/UserMergeForm.class.php
@@ -2,12 +2,9 @@
namespace wcf\acp\form;
-use wcf\data\object\type\ObjectTypeCache;
+use wcf\command\user\MergeUsers;
use wcf\data\user\User;
-use wcf\data\user\UserAction;
-use wcf\data\user\UserEditor;
use wcf\form\AbstractForm;
-use wcf\system\database\util\PreparedStatementConditionBuilder;
use wcf\system\exception\IllegalLinkException;
use wcf\system\exception\PermissionDeniedException;
use wcf\system\exception\UserInputException;
@@ -98,197 +95,18 @@ public function validate()
#[\Override]
public function save()
{
- foreach ($this->userIDs as $userID) {
+ $mergedUsers = [];
+ foreach ($this->users as $userID => $user) {
if ($userID !== $this->destinationUserID) {
+ $mergedUsers[] = $user;
$this->mergedUserIDs[] = $userID;
}
}
parent::save();
- // poll_option_vote
- $conditions = new PreparedStatementConditionBuilder();
- $conditions->add("userID IN (?)", [$this->mergedUserIDs]);
- $sql = "UPDATE IGNORE wcf1_poll_option_vote
- SET userID = ?
- " . $conditions;
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute(\array_merge([$this->destinationUserID], $conditions->getParameters()));
+ new MergeUsers($this->users[$this->destinationUserID], $mergedUsers)();
- // comment
- $conditions = new PreparedStatementConditionBuilder();
- $conditions->add("userID IN (?)", [$this->mergedUserIDs]);
- $sql = "UPDATE wcf1_comment
- SET userID = ?
- " . $conditions;
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute(\array_merge([$this->destinationUserID], $conditions->getParameters()));
-
- // comment_response
- $conditions = new PreparedStatementConditionBuilder();
- $conditions->add("userID IN (?)", [$this->mergedUserIDs]);
- $sql = "UPDATE wcf1_comment_response
- SET userID = ?
- " . $conditions;
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute(\array_merge([$this->destinationUserID], $conditions->getParameters()));
-
- // profile comments
- $objectType = ObjectTypeCache::getInstance()->getObjectTypeByName(
- 'com.woltlab.wcf.comment.commentableContent',
- 'com.woltlab.wcf.user.profileComment'
- );
- $conditions = new PreparedStatementConditionBuilder();
- $conditions->add("objectTypeID = ?", [$objectType->objectTypeID]);
- $conditions->add("objectID IN (?)", [$this->mergedUserIDs]);
- $sql = "UPDATE wcf1_comment
- SET objectID = ?
- " . $conditions;
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute(\array_merge([$this->destinationUserID], $conditions->getParameters()));
-
- // like (userID)
- $conditions = new PreparedStatementConditionBuilder();
- $conditions->add("userID IN (?)", [$this->mergedUserIDs]);
- $sql = "UPDATE IGNORE wcf1_like
- SET userID = ?
- " . $conditions;
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute(\array_merge([$this->destinationUserID], $conditions->getParameters()));
- // like (objectUserID)
- $conditions = new PreparedStatementConditionBuilder();
- $conditions->add("objectUserID IN (?)", [$this->mergedUserIDs]);
- $sql = "UPDATE wcf1_like
- SET objectUserID = ?
- " . $conditions;
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute(\array_merge([$this->destinationUserID], $conditions->getParameters()));
-
- // like_object
- $conditions = new PreparedStatementConditionBuilder();
- $conditions->add("objectUserID IN (?)", [$this->mergedUserIDs]);
- $sql = "UPDATE wcf1_like_object
- SET objectUserID = ?
- " . $conditions;
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute(\array_merge([$this->destinationUserID], $conditions->getParameters()));
-
- // user_follow (userID)
- $conditions = new PreparedStatementConditionBuilder();
- $conditions->add("userID IN (?)", [$this->mergedUserIDs]);
- $conditions->add("followUserID <> ?", [$this->destinationUserID]);
- $sql = "UPDATE IGNORE wcf1_user_follow
- SET userID = ?
- " . $conditions;
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute(\array_merge([$this->destinationUserID], $conditions->getParameters()));
- // user_follow (followUserID)
- $conditions = new PreparedStatementConditionBuilder();
- $conditions->add("followUserID IN (?)", [$this->mergedUserIDs]);
- $conditions->add("userID <> ?", [$this->destinationUserID]);
- $sql = "UPDATE IGNORE wcf1_user_follow
- SET followUserID = ?
- " . $conditions;
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute(\array_merge([$this->destinationUserID], $conditions->getParameters()));
-
- // user_ignore (userID)
- $conditions = new PreparedStatementConditionBuilder();
- $conditions->add("userID IN (?)", [$this->mergedUserIDs]);
- $conditions->add("ignoreUserID <> ?", [$this->destinationUserID]);
- $sql = "UPDATE IGNORE wcf1_user_ignore
- SET userID = ?
- " . $conditions;
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute(\array_merge([$this->destinationUserID], $conditions->getParameters()));
- // user_ignore (ignoreUserID)
- $conditions = new PreparedStatementConditionBuilder();
- $conditions->add("ignoreUserID IN (?)", [$this->mergedUserIDs]);
- $conditions->add("userID <> ?", [$this->destinationUserID]);
- $sql = "UPDATE IGNORE wcf1_user_ignore
- SET ignoreUserID = ?
- " . $conditions;
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute(\array_merge([$this->destinationUserID], $conditions->getParameters()));
-
- // user_object_watch
- $conditions = new PreparedStatementConditionBuilder();
- $conditions->add("userID IN (?)", [$this->mergedUserIDs]);
- $sql = "UPDATE IGNORE wcf1_user_object_watch
- SET userID = ?
- " . $conditions;
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute(\array_merge([$this->destinationUserID], $conditions->getParameters()));
-
- // user_activity_event
- $conditions = new PreparedStatementConditionBuilder();
- $conditions->add("userID IN (?)", [$this->mergedUserIDs]);
- $sql = "UPDATE wcf1_user_activity_event
- SET userID = ?
- " . $conditions;
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute(\array_merge([$this->destinationUserID], $conditions->getParameters()));
-
- // attachments
- $conditions = new PreparedStatementConditionBuilder();
- $conditions->add("userID IN (?)", [$this->mergedUserIDs]);
- $sql = "UPDATE wcf1_attachment
- SET userID = ?
- " . $conditions;
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute(\array_merge([$this->destinationUserID], $conditions->getParameters()));
-
- // modification_log
- $conditions = new PreparedStatementConditionBuilder();
- $conditions->add("userID IN (?)", [$this->mergedUserIDs]);
- $sql = "UPDATE wcf1_modification_log
- SET userID = ?
- " . $conditions;
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute(\array_merge([$this->destinationUserID], $conditions->getParameters()));
-
- // user notifications
- $conditions = new PreparedStatementConditionBuilder();
- $conditions->add("authorID IN (?)", [$this->mergedUserIDs]);
- $sql = "UPDATE IGNORE wcf1_user_notification_author
- SET authorID = ?
- " . $conditions;
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute(\array_merge([$this->destinationUserID], $conditions->getParameters()));
-
- // articles
- $conditions = new PreparedStatementConditionBuilder();
- $conditions->add("userID IN (?)", [$this->mergedUserIDs]);
- $sql = "UPDATE wcf1_article
- SET userID = ?
- " . $conditions;
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute(\array_merge([$this->destinationUserID], $conditions->getParameters()));
-
- // moderation queue
- $conditions = new PreparedStatementConditionBuilder();
- $conditions->add("userID IN (?)", [$this->mergedUserIDs]);
- $sql = "UPDATE wcf" . \WCF_N . "_moderation_queue
- SET userID = ?
- " . $conditions;
- $statement = WCF::getDB()->prepareStatement($sql);
- $statement->execute(\array_merge([$this->destinationUserID], $conditions->getParameters()));
-
- // edit history
- $conditions = new PreparedStatementConditionBuilder();
- $conditions->add("userID IN (?)", [$this->mergedUserIDs]);
- $sql = "UPDATE wcf" . \WCF_N . "_edit_history_entry
- SET userID = ?
- " . $conditions;
- $statement = WCF::getDB()->prepareStatement($sql);
- $statement->execute(\array_merge([$this->destinationUserID], $conditions->getParameters()));
-
- // delete merged users
- $action = new UserAction($this->mergedUserIDs, 'delete');
- $action->executeAction();
-
- // reset clipboard
- UserEditor::resetCache();
$this->saved();
// show success message
diff --git a/wcfsetup/install/files/lib/command/user/MergeUsers.class.php b/wcfsetup/install/files/lib/command/user/MergeUsers.class.php
new file mode 100644
index 00000000000..153f0bbdc99
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/user/MergeUsers.class.php
@@ -0,0 +1,122 @@
+
+ * @since 6.3
+ */
+final class MergeUsers
+{
+ /**
+ * @var int[]
+ */
+ private readonly array $mergedUserIDs;
+
+ /**
+ * @param User[] $mergedUsers users that are merged into the destination user
+ */
+ public function __construct(
+ private readonly User $destinationUser,
+ private readonly array $mergedUsers,
+ ) {
+ $this->mergedUserIDs = \array_values(\array_map(
+ static fn(User $user) => $user->userID,
+ $this->mergedUsers
+ ));
+
+ if (\in_array($this->destinationUser->userID, $this->mergedUserIDs, true)) {
+ throw new \InvalidArgumentException("The destination user must not be one of the merged users.");
+ }
+ }
+
+ public function __invoke(): void
+ {
+ if ($this->mergedUserIDs === []) {
+ return;
+ }
+
+ $this->updateUserID('wcf1_poll_option_vote', ignore: true);
+ $this->updateUserID('wcf1_comment');
+ $this->updateUserID('wcf1_comment_response');
+ $this->updateProfileComments();
+ $this->updateUserID('wcf1_like', ignore: true);
+ $this->updateUserID('wcf1_like', 'objectUserID');
+ $this->updateUserID('wcf1_like_object', 'objectUserID');
+ $this->updateUserID('wcf1_user_follow', ignore: true, excludedColumnName: 'followUserID');
+ $this->updateUserID('wcf1_user_follow', 'followUserID', true, 'userID');
+ $this->updateUserID('wcf1_user_ignore', ignore: true, excludedColumnName: 'ignoreUserID');
+ $this->updateUserID('wcf1_user_ignore', 'ignoreUserID', true, 'userID');
+ $this->updateUserID('wcf1_user_object_watch', ignore: true);
+ $this->updateUserID('wcf1_user_activity_event');
+ $this->updateUserID('wcf1_attachment');
+ $this->updateUserID('wcf1_modification_log');
+ $this->updateUserID('wcf1_user_notification_author', 'authorID', true);
+ $this->updateUserID('wcf1_article');
+ $this->updateUserID('wcf1_moderation_queue');
+ $this->updateUserID('wcf1_edit_history_entry');
+
+ EventHandler::getInstance()->fire(
+ new UsersMerging($this->destinationUser, $this->mergedUsers)
+ );
+
+ foreach ($this->mergedUsers as $user) {
+ new DeleteUser($user)();
+ }
+ }
+
+ /**
+ * Replaces the ids of the merged users in the given column with the id of
+ * the destination user.
+ *
+ * Rows whose `$excludedColumnName` references the destination user are
+ * skipped, e.g. to prevent the destination user from following themselves.
+ */
+ private function updateUserID(
+ string $tableName,
+ string $columnName = 'userID',
+ bool $ignore = false,
+ ?string $excludedColumnName = null
+ ): void {
+ $conditions = new PreparedStatementConditionBuilder();
+ $conditions->add("{$columnName} IN (?)", [$this->mergedUserIDs]);
+ if ($excludedColumnName !== null) {
+ $conditions->add("{$excludedColumnName} <> ?", [$this->destinationUser->userID]);
+ }
+
+ $sql = "UPDATE" . ($ignore ? " IGNORE" : "") . " {$tableName}
+ SET {$columnName} = ?
+ " . $conditions;
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute(\array_merge([$this->destinationUser->userID], $conditions->getParameters()));
+ }
+
+ private function updateProfileComments(): void
+ {
+ $objectType = ObjectTypeCache::getInstance()->getObjectTypeByName(
+ 'com.woltlab.wcf.comment.commentableContent',
+ 'com.woltlab.wcf.user.profileComment'
+ );
+
+ $conditions = new PreparedStatementConditionBuilder();
+ $conditions->add("objectTypeID = ?", [$objectType->objectTypeID]);
+ $conditions->add("objectID IN (?)", [$this->mergedUserIDs]);
+
+ $sql = "UPDATE wcf1_comment
+ SET objectID = ?
+ " . $conditions;
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute(\array_merge([$this->destinationUser->userID], $conditions->getParameters()));
+ }
+}
diff --git a/wcfsetup/install/files/lib/event/user/UsersMerging.class.php b/wcfsetup/install/files/lib/event/user/UsersMerging.class.php
new file mode 100644
index 00000000000..c7ef24fb1ae
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/user/UsersMerging.class.php
@@ -0,0 +1,28 @@
+
+ * @since 6.3
+ */
+final class UsersMerging implements IPsr14Event
+{
+ /**
+ * @param User[] $mergedUsers
+ */
+ public function __construct(
+ public readonly User $destinationUser,
+ public readonly array $mergedUsers,
+ ) {}
+}
From 4c0ce2727402c61bcd2c9826192499c2308482d1 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Mon, 5 Oct 2026 13:33:32 +0200
Subject: [PATCH 023/115] Remove obsolete user clipboard unmarking
---
.../files/lib/command/user/DeleteUser.class.php | 6 ------
.../files/lib/data/user/UserAction.class.php | 15 ++-------------
.../files/lib/data/user/UserEditor.class.php | 13 -------------
.../lib/system/worker/MailWorker.class.php | 5 -----
.../worker/SendNewPasswordWorker.class.php | 17 -----------------
.../worker/UserContentRemoveWorker.class.php | 6 ------
6 files changed, 2 insertions(+), 60 deletions(-)
diff --git a/wcfsetup/install/files/lib/command/user/DeleteUser.class.php b/wcfsetup/install/files/lib/command/user/DeleteUser.class.php
index 4bfa7948d53..72af63d6d77 100644
--- a/wcfsetup/install/files/lib/command/user/DeleteUser.class.php
+++ b/wcfsetup/install/files/lib/command/user/DeleteUser.class.php
@@ -8,7 +8,6 @@
use wcf\data\user\UserBuilder;
use wcf\event\user\UserDeleted;
use wcf\system\attachment\AttachmentHandler;
-use wcf\system\clipboard\ClipboardHandler;
use wcf\system\comment\CommentHandler;
use wcf\system\event\EventHandler;
@@ -37,11 +36,6 @@ public function __invoke(): void
private function cleanup(User $user): void
{
- ClipboardHandler::getInstance()->unmark(
- [$user->userID],
- ClipboardHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.user')
- );
-
$fileIDs = \array_values(\array_filter([$user->avatarFileID, $user->coverPhotoFileID]));
if ($fileIDs !== []) {
$files = File::findByIDs($fileIDs);
diff --git a/wcfsetup/install/files/lib/data/user/UserAction.class.php b/wcfsetup/install/files/lib/data/user/UserAction.class.php
index 1bd38bdb754..e79943fc5f9 100644
--- a/wcfsetup/install/files/lib/data/user/UserAction.class.php
+++ b/wcfsetup/install/files/lib/data/user/UserAction.class.php
@@ -16,7 +16,6 @@
use wcf\data\ISearchAction;
use wcf\data\user\group\UserGroup;
use wcf\system\attachment\AttachmentHandler;
-use wcf\system\clipboard\ClipboardHandler;
use wcf\system\comment\CommentHandler;
use wcf\system\database\util\PreparedStatementConditionBuilder;
use wcf\system\event\EventHandler;
@@ -24,7 +23,6 @@
use wcf\system\exception\PermissionDeniedException;
use wcf\system\exception\UserInputException;
use wcf\system\language\LanguageFactory;
-use wcf\system\request\RequestHandler;
use wcf\system\session\SessionHandler;
use wcf\system\style\FontAwesomeIcon;
use wcf\system\user\group\assignment\UserGroupAssignmentHandler;
@@ -608,7 +606,7 @@ public function validateUnmarkAll()
#[\Override]
public function unmarkAll()
{
- ClipboardHandler::getInstance()->removeItems(ClipboardHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.user'));
+ // does nothing
}
/**
@@ -619,16 +617,7 @@ public function unmarkAll()
*/
protected function unmarkItems(array $userIDs = [])
{
- if ($userIDs === []) {
- $userIDs = $this->objectIDs;
- }
-
- if ($userIDs !== []) {
- ClipboardHandler::getInstance()->unmark(
- $userIDs,
- ClipboardHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.user')
- );
- }
+ // does nothing
}
/**
diff --git a/wcfsetup/install/files/lib/data/user/UserEditor.class.php b/wcfsetup/install/files/lib/data/user/UserEditor.class.php
index d2d8398a4e9..4f1b60c278d 100644
--- a/wcfsetup/install/files/lib/data/user/UserEditor.class.php
+++ b/wcfsetup/install/files/lib/data/user/UserEditor.class.php
@@ -6,7 +6,6 @@
use wcf\data\DatabaseObjectEditor;
use wcf\data\IEditableCachedObject;
use wcf\data\user\group\UserGroup;
-use wcf\system\clipboard\ClipboardHandler;
use wcf\system\language\LanguageFactory;
use wcf\system\user\authentication\password\algorithm\Invalid as InvalidPasswordAlgorithm;
use wcf\system\user\authentication\password\PasswordAlgorithmManager;
@@ -84,18 +83,6 @@ public static function create(array $parameters = [])
return $user;
}
- #[\Override]
- public static function deleteAll(array $objectIDs = [])
- {
- // unmark users
- ClipboardHandler::getInstance()->unmark(
- $objectIDs,
- ClipboardHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.user')
- );
-
- return parent::deleteAll($objectIDs);
- }
-
#[\Override]
public function update(array $parameters = [])
{
diff --git a/wcfsetup/install/files/lib/system/worker/MailWorker.class.php b/wcfsetup/install/files/lib/system/worker/MailWorker.class.php
index 6bc5cacbec7..d0246497161 100644
--- a/wcfsetup/install/files/lib/system/worker/MailWorker.class.php
+++ b/wcfsetup/install/files/lib/system/worker/MailWorker.class.php
@@ -5,7 +5,6 @@
use wcf\acp\page\UserListPage;
use wcf\data\user\User;
use wcf\system\background\BackgroundQueueHandler;
-use wcf\system\clipboard\ClipboardHandler;
use wcf\system\database\util\PreparedStatementConditionBuilder;
use wcf\system\email\Email;
use wcf\system\email\Mailbox;
@@ -105,10 +104,6 @@ public function getProgress()
$progress = parent::getProgress();
if ($progress === 100) {
- // clear markings
- $typeID = ClipboardHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.user');
- ClipboardHandler::getInstance()->removeItems($typeID);
-
// clear session
$userMailData = WCF::getSession()->getVar('userMailData');
unset($userMailData[$this->parameters['mailID']]);
diff --git a/wcfsetup/install/files/lib/system/worker/SendNewPasswordWorker.class.php b/wcfsetup/install/files/lib/system/worker/SendNewPasswordWorker.class.php
index 2ddd360af94..481e950cc5f 100644
--- a/wcfsetup/install/files/lib/system/worker/SendNewPasswordWorker.class.php
+++ b/wcfsetup/install/files/lib/system/worker/SendNewPasswordWorker.class.php
@@ -6,7 +6,6 @@
use wcf\command\user\SendNewPassword;
use wcf\data\user\group\UserGroup;
use wcf\data\user\UserList;
-use wcf\system\clipboard\ClipboardHandler;
use wcf\system\exception\PermissionDeniedException;
use wcf\system\exception\SystemException;
use wcf\system\request\LinkHandler;
@@ -56,22 +55,6 @@ public function getProceedURL()
return LinkHandler::getInstance()->getControllerLink(UserListPage::class);
}
- #[\Override]
- public function getProgress()
- {
- $progress = parent::getProgress();
-
- if ($progress === 100) {
- // unmark users
- ClipboardHandler::getInstance()->unmark(
- $this->parameters['userIDs'],
- ClipboardHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.user')
- );
- }
-
- return $progress;
- }
-
#[\Override]
public function validate()
{
diff --git a/wcfsetup/install/files/lib/system/worker/UserContentRemoveWorker.class.php b/wcfsetup/install/files/lib/system/worker/UserContentRemoveWorker.class.php
index f854c3434fb..321e4f9097b 100644
--- a/wcfsetup/install/files/lib/system/worker/UserContentRemoveWorker.class.php
+++ b/wcfsetup/install/files/lib/system/worker/UserContentRemoveWorker.class.php
@@ -10,7 +10,6 @@
use wcf\data\user\group\UserGroup;
use wcf\data\user\User;
use wcf\data\user\UserList;
-use wcf\system\clipboard\ClipboardHandler;
use wcf\system\exception\PermissionDeniedException;
use wcf\system\request\LinkHandler;
use wcf\system\user\content\provider\IUserContentProvider;
@@ -245,11 +244,6 @@ public function finalize()
$dataArray[$this->generateKey()] = $this->data;
WCF::getSession()->register(self::USER_CONTENT_REMOVE_WORKER_SESSION_NAME, $dataArray);
-
- ClipboardHandler::getInstance()->unmark(
- \array_column($this->users, 'userID'),
- ClipboardHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.user')
- );
}
#[\Override]
From d4aeeb9eb02ba22ca59765f4770a97977f15add2 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Mon, 5 Oct 2026 13:43:19 +0200
Subject: [PATCH 024/115] Remove obsolete user, tag and article clipboard
object types
---
com.woltlab.wcf/objectType.xml | 24 +++++++------------
.../lib/data/article/ArticleAction.class.php | 8 -------
2 files changed, 9 insertions(+), 23 deletions(-)
diff --git a/com.woltlab.wcf/objectType.xml b/com.woltlab.wcf/objectType.xml
index 6ee56891aaa..1bf82f8982e 100644
--- a/com.woltlab.wcf/objectType.xml
+++ b/com.woltlab.wcf/objectType.xml
@@ -2,26 +2,11 @@
-
- com.woltlab.wcf.user
- com.woltlab.wcf.clipboardItem
- wcf\data\user\UserList
-
-
- com.woltlab.wcf.tag
- com.woltlab.wcf.clipboardItem
- wcf\data\tag\TagList
-
com.woltlab.wcf.media
com.woltlab.wcf.clipboardItem
wcf\data\media\ViewableMediaList
-
- com.woltlab.wcf.article
- com.woltlab.wcf.clipboardItem
- wcf\data\article\ArticleList
-
@@ -1613,5 +1598,14 @@
com.woltlab.wcf.condition.ad
+
+ com.woltlab.wcf.clipboardItem
+
+
+ com.woltlab.wcf.clipboardItem
+
+
+ com.woltlab.wcf.clipboardItem
+
diff --git a/wcfsetup/install/files/lib/data/article/ArticleAction.class.php b/wcfsetup/install/files/lib/data/article/ArticleAction.class.php
index 0afa8b2a8d9..9b0a49eb153 100644
--- a/wcfsetup/install/files/lib/data/article/ArticleAction.class.php
+++ b/wcfsetup/install/files/lib/data/article/ArticleAction.class.php
@@ -20,7 +20,6 @@
use wcf\data\article\content\ArticleContentEditor;
use wcf\data\language\Language;
use wcf\system\attachment\AttachmentHandler;
-use wcf\system\clipboard\ClipboardHandler;
use wcf\system\comment\CommentHandler;
use wcf\system\exception\IllegalLinkException;
use wcf\system\exception\PermissionDeniedException;
@@ -669,13 +668,6 @@ public function validateSetCategory()
{
WCF::getSession()->checkPermissions(['admin.content.article.canManageArticle']);
- $this->readBoolean('useMarkedArticles', true);
-
- // if no object ids are given, use clipboard handler
- if ($this->objectIDs === [] && $this->parameters['useMarkedArticles'] === true) {
- $this->objectIDs = \array_keys(ClipboardHandler::getInstance()->getMarkedItems(ClipboardHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.article')));
- }
-
if ($this->objects === []) {
$this->readObjects();
From 815bf8a74c6b0bb6ace2c7321c821b4644397cd4 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Mon, 5 Oct 2026 13:45:16 +0200
Subject: [PATCH 025/115] Remove obsolete tag `SynonymsHandler` clipboard
module
---
.../Core/Acp/Component/Tag/SynonymsHandler.ts | 48 -------------------
.../Core/Acp/Component/Tag/SynonymsHandler.js | 40 ----------------
2 files changed, 88 deletions(-)
delete mode 100644 ts/WoltLabSuite/Core/Acp/Component/Tag/SynonymsHandler.ts
delete mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Component/Tag/SynonymsHandler.js
diff --git a/ts/WoltLabSuite/Core/Acp/Component/Tag/SynonymsHandler.ts b/ts/WoltLabSuite/Core/Acp/Component/Tag/SynonymsHandler.ts
deleted file mode 100644
index 504178bb456..00000000000
--- a/ts/WoltLabSuite/Core/Acp/Component/Tag/SynonymsHandler.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-/**
- * Handles the dialog to set tags as synonyms.
- *
- * @author Olaf Braun
- * @copyright 2001-2024 WoltLab GmbH
- * @license GNU Lesser General Public License
- */
-
-import { add as addEvent } from "WoltLabSuite/Core/Event/Handler";
-import { ClipboardActionData } from "WoltLabSuite/Core/Controller/Clipboard/Data";
-import { dialogFactory } from "WoltLabSuite/Core/Component/Dialog";
-import { getPhrase } from "WoltLabSuite/Core/Language";
-import DomUtil from "WoltLabSuite/Core/Dom/Util";
-import { dboAction } from "WoltLabSuite/Core/Ajax";
-
-export function init() {
- addEvent("com.woltlab.wcf.clipboard", "com.woltlab.wcf.tag", (actionData: { data: ClipboardActionData }) => {
- if (actionData.data.actionName === "com.woltlab.wcf.tag.setAsSynonyms") {
- openDialog(actionData.data.parameters.objectIDs, actionData.data.parameters.template);
- }
- });
-}
-
-function openDialog(objectIDs: number[], template: string) {
- const dialog = dialogFactory().fromHtml(template).asConfirmation();
- dialog.addEventListener("validate", (event) => {
- const checked = dialog.querySelectorAll("input[type=radio]:checked").length > 0;
- event.detail.push(Promise.resolve(checked));
-
- DomUtil.innerError(
- dialog.querySelector(".containerBoxList")!,
- checked ? undefined : getPhrase("wcf.global.form.error.empty"),
- );
- });
- dialog.addEventListener("primary", () => {
- void dboAction("setAsSynonyms", "wcf\\data\\tag\\TagAction")
- .objectIds(objectIDs)
- .payload({
- tagID: dialog.querySelector('input[name="tagID"]:checked')!.value,
- })
- .dispatch()
- .then(() => {
- window.location.reload();
- });
- });
-
- dialog.show(getPhrase("wcf.acp.tag.setAsSynonyms"));
-}
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Component/Tag/SynonymsHandler.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Component/Tag/SynonymsHandler.js
deleted file mode 100644
index 3f3cf675dcc..00000000000
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Component/Tag/SynonymsHandler.js
+++ /dev/null
@@ -1,40 +0,0 @@
-/**
- * Handles the dialog to set tags as synonyms.
- *
- * @author Olaf Braun
- * @copyright 2001-2024 WoltLab GmbH
- * @license GNU Lesser General Public License
- */
-define(["require", "exports", "tslib", "WoltLabSuite/Core/Event/Handler", "WoltLabSuite/Core/Component/Dialog", "WoltLabSuite/Core/Language", "WoltLabSuite/Core/Dom/Util", "WoltLabSuite/Core/Ajax"], function (require, exports, tslib_1, Handler_1, Dialog_1, Language_1, Util_1, Ajax_1) {
- "use strict";
- Object.defineProperty(exports, "__esModule", { value: true });
- exports.init = init;
- Util_1 = tslib_1.__importDefault(Util_1);
- function init() {
- (0, Handler_1.add)("com.woltlab.wcf.clipboard", "com.woltlab.wcf.tag", (actionData) => {
- if (actionData.data.actionName === "com.woltlab.wcf.tag.setAsSynonyms") {
- openDialog(actionData.data.parameters.objectIDs, actionData.data.parameters.template);
- }
- });
- }
- function openDialog(objectIDs, template) {
- const dialog = (0, Dialog_1.dialogFactory)().fromHtml(template).asConfirmation();
- dialog.addEventListener("validate", (event) => {
- const checked = dialog.querySelectorAll("input[type=radio]:checked").length > 0;
- event.detail.push(Promise.resolve(checked));
- Util_1.default.innerError(dialog.querySelector(".containerBoxList"), checked ? undefined : (0, Language_1.getPhrase)("wcf.global.form.error.empty"));
- });
- dialog.addEventListener("primary", () => {
- void (0, Ajax_1.dboAction)("setAsSynonyms", "wcf\\data\\tag\\TagAction")
- .objectIds(objectIDs)
- .payload({
- tagID: dialog.querySelector('input[name="tagID"]:checked').value,
- })
- .dispatch()
- .then(() => {
- window.location.reload();
- });
- });
- dialog.show((0, Language_1.getPhrase)("wcf.acp.tag.setAsSynonyms"));
- }
-});
From 57898ce8122d0158355857945a08d9b2c5a66534 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Mon, 5 Oct 2026 13:56:22 +0200
Subject: [PATCH 026/115] Drop `IClipboardAction` from `UserAction` and
deprecate unmark methods
---
.../files/lib/data/user/UserAction.class.php | 29 +++++++++----------
1 file changed, 13 insertions(+), 16 deletions(-)
diff --git a/wcfsetup/install/files/lib/data/user/UserAction.class.php b/wcfsetup/install/files/lib/data/user/UserAction.class.php
index e79943fc5f9..42ebcb6788c 100644
--- a/wcfsetup/install/files/lib/data/user/UserAction.class.php
+++ b/wcfsetup/install/files/lib/data/user/UserAction.class.php
@@ -12,7 +12,6 @@
use wcf\command\user\UnconfirmUserEmail;
use wcf\data\AbstractDatabaseObjectAction;
use wcf\data\file\File;
-use wcf\data\IClipboardAction;
use wcf\data\ISearchAction;
use wcf\data\user\group\UserGroup;
use wcf\system\attachment\AttachmentHandler;
@@ -37,7 +36,7 @@
*
* @extends AbstractDatabaseObjectAction
*/
-class UserAction extends AbstractDatabaseObjectAction implements IClipboardAction, ISearchAction
+class UserAction extends AbstractDatabaseObjectAction implements ISearchAction
{
/**
* @inheritDoc
@@ -228,8 +227,6 @@ public function ban()
(new BanUser($userEditor->getDecoratedObject(), $this->parameters['banReason'], $banExpires))();
}
- $this->unmarkItems();
-
$firstUser = new User(\reset($this->objectIDs));
return WCF::getLanguage()->getDynamicVariable('wcf.user.banned', ['user' => $firstUser]);
@@ -597,13 +594,21 @@ public function getSearchResultList()
return $list;
}
- #[\Override]
+ /**
+ * @return void
+ *
+ * @deprecated 6.3
+ */
public function validateUnmarkAll()
{
// does nothing
}
- #[\Override]
+ /**
+ * @return void
+ *
+ * @deprecated 6.3
+ */
public function unmarkAll()
{
// does nothing
@@ -614,6 +619,8 @@ public function unmarkAll()
*
* @param int[] $userIDs
* @return void
+ *
+ * @deprecated 6.3
*/
protected function unmarkItems(array $userIDs = [])
{
@@ -679,8 +686,6 @@ public function confirmEmail()
foreach ($this->getObjects() as $userEditor) {
(new ConfirmUserEmail($userEditor->getDecoratedObject()))();
}
-
- $this->unmarkItems();
}
/**
@@ -698,8 +703,6 @@ public function unconfirmEmail()
foreach ($this->getObjects() as $userEditor) {
(new UnconfirmUserEmail($userEditor->getDecoratedObject()))();
}
-
- $this->unmarkItems();
}
/**
@@ -717,8 +720,6 @@ public function enable()
foreach ($this->getObjects() as $userEditor) {
(new EnableUser($userEditor->getDecoratedObject(), $sendNotification))();
}
-
- $this->unmarkItems();
}
/**
@@ -735,8 +736,6 @@ public function disable()
foreach ($this->getObjects() as $userEditor) {
(new DisableUser($userEditor->getDecoratedObject()))();
}
-
- $this->unmarkItems();
}
#[\Override]
@@ -1058,8 +1057,6 @@ public function resendActivationMail()
foreach ($this->getObjects() as $userEditor) {
(new ResendActivationMail($userEditor->getDecoratedObject()))();
}
-
- $this->unmarkItems($this->objectIDs);
}
/**
From 40d64851d3f20bd89b28151176a9bb81ed779991 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Mon, 5 Oct 2026 15:27:50 +0200
Subject: [PATCH 027/115] =?UTF-8?q?Configure=20default=20columns=20for=20`?=
=?UTF-8?q?UserGridView`=1B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../files/lib/system/gridView/admin/UserGridView.class.php | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php
index 6f8a855a696..107bf847151 100644
--- a/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php
+++ b/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php
@@ -99,6 +99,7 @@ public function __construct()
->label('wcf.user.registrationIpAddress')
->renderer(new IpAddressColumnRenderer())
->filter(IpAddressFilter::class)
+ ->hidden()
);
}
@@ -112,12 +113,14 @@ public function __construct()
->label('wcf.user.activityPoint')
->renderer(new NumberColumnRenderer())
->filter(IntegerFilter::class)
- ->sortable(defaultSortOrder: 'DESC'),
+ ->sortable(defaultSortOrder: 'DESC')
+ ->hidden(),
GridViewColumn::for('likesReceived')
->label('wcf.like.likesReceived')
->renderer(new NumberColumnRenderer())
->filter(IntegerFilter::class)
- ->sortable(defaultSortOrder: 'DESC'),
+ ->sortable(defaultSortOrder: 'DESC')
+ ->hidden(),
]);
$this->addAvailableFilters([
From fd78edeefabdd492865a62f3610459728ae8edc6 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Mon, 5 Oct 2026 16:18:57 +0200
Subject: [PATCH 028/115] Add user-configurable column visibility to grid views
Closes #6179
---
com.woltlab.wcf/templates/shared_gridView.tpl | 43 ++----
.../shared_gridViewHeaderColumns.tpl | 23 +++
ts/WoltLabSuite/Core/Api/GridViews/GetRow.ts | 6 +
ts/WoltLabSuite/Core/Api/GridViews/GetRows.ts | 11 ++
ts/WoltLabSuite/Core/Component/GridView.ts | 34 ++++-
.../Component/GridView/ColumnConfiguration.ts | 98 ++++++++++++
.../Core/Component/GridView/Sorting.ts | 19 ++-
.../Core/Component/GridView/State.ts | 28 +++-
.../WoltLabSuite/Core/Api/GridViews/GetRow.js | 7 +-
.../Core/Api/GridViews/GetRows.js | 10 +-
.../WoltLabSuite/Core/Component/GridView.js | 27 +++-
.../Component/GridView/ColumnConfiguration.js | 68 +++++++++
.../Core/Component/GridView/Sorting.js | 16 +-
.../Core/Component/GridView/State.js | 24 ++-
...ridViewColumnConfigurationAction.class.php | 139 ++++++++++++++++++
.../lib/page/AbstractGridViewPage.class.php | 14 ++
.../core/gridViews/GetRow.class.php | 6 +
.../core/gridViews/GetRows.class.php | 17 ++-
.../gridView/AbstractGridView.class.php | 123 +++++++++++++++-
.../gridView/admin/UserGridView.class.php | 1 +
wcfsetup/install/files/style/ui/gridView.scss | 2 +
wcfsetup/install/lang/de.xml | 1 +
wcfsetup/install/lang/en.xml | 1 +
23 files changed, 650 insertions(+), 68 deletions(-)
create mode 100644 com.woltlab.wcf/templates/shared_gridViewHeaderColumns.tpl
create mode 100644 ts/WoltLabSuite/Core/Component/GridView/ColumnConfiguration.ts
create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView/ColumnConfiguration.js
create mode 100644 wcfsetup/install/files/lib/action/GridViewColumnConfigurationAction.class.php
diff --git a/com.woltlab.wcf/templates/shared_gridView.tpl b/com.woltlab.wcf/templates/shared_gridView.tpl
index b44f4a2161c..381d1e33f05 100644
--- a/com.woltlab.wcf/templates/shared_gridView.tpl
+++ b/com.woltlab.wcf/templates/shared_gridView.tpl
@@ -1,5 +1,5 @@
- {if $view->isFilterable()}
+ {if $view->isFilterable() || $view->isColumnConfigurable()}
{foreach from=$view->getActiveFilters() item='value' key='key'}
@@ -16,10 +16,18 @@
{/foreach}
-
- {icon name='sliders'}
- {lang}wcf.global.filter{/lang}
-
+ {if $view->isColumnConfigurable()}
+
+ {icon name='table-columns'}
+ {lang}wcf.global.gridView.columns{/lang}
+
+ {/if}
+ {if $view->isFilterable()}
+
+ {icon name='sliders'}
+ {lang}wcf.global.filter{/lang}
+
+ {/if}
{/if}
@@ -33,29 +41,7 @@
{/if}
- {foreach from=$view->getVisibleColumns() item='column'}
-
isSortable()}data-default-sort-order="{$column->getDefaultSortOrder()}"{/if}
- {if $column->isSortable()}
- {if $view->isSortedBy($column)}
- aria-sort="{if $view->getSortOrder() === 'ASC'}ascending{else}descending{/if}"
- {else}
- aria-sort="none"
- {/if}
- {/if}
- >
- {if $column->isSortable()}
-
- {unsafe:$column->getLabel()}
-
- {else}
- {unsafe:$column->getLabel()}
- {/if}
-
- {/foreach}
+ {unsafe:$view->renderHeaderColumns()}
{if $view->hasInteractions()}
{/if}
@@ -117,6 +103,7 @@
['{unsafe:$name|encodeJS}', {unsafe:$value|json}],
{/foreach}
]),
+ {if $view->getVisibleColumnIDs() !== null}{unsafe:$view->getVisibleColumnIDs()|json}{else}undefined{/if},
);
});
diff --git a/com.woltlab.wcf/templates/shared_gridViewHeaderColumns.tpl b/com.woltlab.wcf/templates/shared_gridViewHeaderColumns.tpl
new file mode 100644
index 00000000000..6f3972ce465
--- /dev/null
+++ b/com.woltlab.wcf/templates/shared_gridViewHeaderColumns.tpl
@@ -0,0 +1,23 @@
+{foreach from=$view->getVisibleColumns() item='column'}
+
isSortable()}data-default-sort-order="{$column->getDefaultSortOrder()}"{/if}
+ {if $column->isSortable()}
+ {if $view->isSortedBy($column)}
+ aria-sort="{if $view->getSortOrder() === 'ASC'}ascending{else}descending{/if}"
+ {else}
+ aria-sort="none"
+ {/if}
+ {/if}
+ >
+ {if $column->isSortable()}
+
+ {unsafe:$column->getLabel()}
+
+ {else}
+ {unsafe:$column->getLabel()}
+ {/if}
+
+{/foreach}
diff --git a/ts/WoltLabSuite/Core/Api/GridViews/GetRow.ts b/ts/WoltLabSuite/Core/Api/GridViews/GetRow.ts
index f1f0a4864d0..1a14ee947e7 100644
--- a/ts/WoltLabSuite/Core/Api/GridViews/GetRow.ts
+++ b/ts/WoltLabSuite/Core/Api/GridViews/GetRow.ts
@@ -19,6 +19,7 @@ export async function getRow(
objectId: string | number,
filters?: Map
,
gridViewParameters?: Map,
+ columns?: string[],
): Promise {
const url = new URL(`${window.WSC_RPC_API_URL}core/grid-views/row`);
url.searchParams.set("gridView", gridViewClass);
@@ -39,6 +40,11 @@ export async function getRow(
}
});
}
+ if (columns) {
+ columns.forEach((column, index) => {
+ url.searchParams.set(`columns[${index}]`, column);
+ });
+ }
return fromInfallibleApiRequest(() => {
return prepareRequest(url).get().allowCaching().disableLoadingIndicator().fetchAsJson();
diff --git a/ts/WoltLabSuite/Core/Api/GridViews/GetRows.ts b/ts/WoltLabSuite/Core/Api/GridViews/GetRows.ts
index b45595222e2..7b7dc81efbf 100644
--- a/ts/WoltLabSuite/Core/Api/GridViews/GetRows.ts
+++ b/ts/WoltLabSuite/Core/Api/GridViews/GetRows.ts
@@ -15,6 +15,7 @@ type Response = {
pages: number;
totalRows: number;
filterLabels: ArrayLike;
+ header?: string;
};
export async function getRows(
@@ -24,6 +25,8 @@ export async function getRows(
sortOrder: string = "ASC",
filters?: Map,
gridViewParameters?: Map,
+ columns?: string[],
+ includeHeader = false,
): Promise {
const url = new URL(`${window.WSC_RPC_API_URL}core/grid-views/rows`);
url.searchParams.set("gridView", gridViewClass);
@@ -46,6 +49,14 @@ export async function getRows(
}
});
}
+ if (columns) {
+ columns.forEach((column, index) => {
+ url.searchParams.set(`columns[${index}]`, column);
+ });
+ }
+ if (includeHeader) {
+ url.searchParams.set("includeHeader", "true");
+ }
return fromInfallibleApiRequest(() => {
return prepareRequest(url).get().allowCaching().disableLoadingIndicator().fetchAsJson();
diff --git a/ts/WoltLabSuite/Core/Component/GridView.ts b/ts/WoltLabSuite/Core/Component/GridView.ts
index 78338c938f9..44fc6b7c49d 100644
--- a/ts/WoltLabSuite/Core/Component/GridView.ts
+++ b/ts/WoltLabSuite/Core/Component/GridView.ts
@@ -37,6 +37,7 @@ export class GridView {
defaultSortOrder = "ASC",
bulkInteractionProviderClassName: string,
gridViewParameters?: Map,
+ visibleColumns?: string[],
) {
this.#gridClassName = gridClassName;
this.#table = document.getElementById(`${gridId}_table`) as HTMLTableElement;
@@ -45,11 +46,20 @@ export class GridView {
this.#gridViewParameters = gridViewParameters;
this.#initInteractions();
- this.#state = this.#setupState(gridId, pageNo, baseUrl, sortField, sortOrder, defaultSortField, defaultSortOrder);
+ this.#state = this.#setupState(
+ gridId,
+ pageNo,
+ baseUrl,
+ sortField,
+ sortOrder,
+ defaultSortField,
+ defaultSortOrder,
+ visibleColumns,
+ );
this.#initEventListeners();
}
- async #loadRows(cause: StateChangeCause): Promise {
+ async #loadRows(cause: StateChangeCause, includeHeader = false): Promise {
const response = await getRows(
this.#gridClassName,
this.#state.getPageNo(),
@@ -57,7 +67,12 @@ export class GridView {
this.#state.getSortOrder(),
this.#state.getActiveFilters(),
this.#gridViewParameters,
+ this.#state.getVisibleColumns(),
+ includeHeader,
);
+ if (response.header !== undefined) {
+ this.#replaceHeaderColumns(response.header);
+ }
DomUtil.setInnerHtml(this.#table.querySelector("tbody")!, response.template);
this.#table.hidden = response.totalRows == 0;
@@ -73,6 +88,7 @@ export class GridView {
row.dataset.objectId!,
this.#state.getActiveFilters(),
this.#gridViewParameters,
+ this.#state.getVisibleColumns(),
);
row.replaceWith(DomUtil.createFragmentFromHtml(template));
@@ -81,6 +97,16 @@ export class GridView {
this.#checkEmptyTable();
}
+ #replaceHeaderColumns(html: string): void {
+ const headerRow = this.#table.querySelector("thead .gridView__headerRow")!;
+ headerRow.querySelectorAll("th[data-id]").forEach((element) => element.remove());
+
+ // The selection and action columns are not part of the configurable
+ // columns and must be preserved.
+ const actionColumn = headerRow.querySelector(".gridView__actionColumn");
+ headerRow.insertBefore(DomUtil.createFragmentFromHtml(html), actionColumn);
+ }
+
#initInteractions(): void {
wheneverFirstSeen(`#${this.#table.id} tbody tr`, (row) => {
const containers = [row];
@@ -149,6 +175,7 @@ export class GridView {
sortOrder: string,
defaultSortField: string,
defaultSortOrder: string,
+ visibleColumns?: string[],
): State {
const state = new State(
gridId,
@@ -159,9 +186,10 @@ export class GridView {
sortOrder,
defaultSortField,
defaultSortOrder,
+ visibleColumns,
);
state.addEventListener("grid-view:change", (event) => {
- void this.#loadRows(event.detail.source);
+ void this.#loadRows(event.detail.source, event.detail.includeHeader);
});
state.addEventListener(
"grid-view:get-bulk-interactions",
diff --git a/ts/WoltLabSuite/Core/Component/GridView/ColumnConfiguration.ts b/ts/WoltLabSuite/Core/Component/GridView/ColumnConfiguration.ts
new file mode 100644
index 00000000000..9d7093db872
--- /dev/null
+++ b/ts/WoltLabSuite/Core/Component/GridView/ColumnConfiguration.ts
@@ -0,0 +1,98 @@
+/**
+ * Handles the configuration of the visible columns of grid views. The
+ * configuration is not persisted, but it is part of the url.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.3
+ */
+
+import { promiseMutex } from "../../Helper/PromiseMutex";
+import { dialogFactory } from "../Dialog";
+
+// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
+export class ColumnConfiguration extends EventTarget {
+ readonly #button: HTMLButtonElement | null;
+ #visibleColumns?: string[];
+
+ constructor(gridId: string, visibleColumns?: string[]) {
+ super();
+
+ this.#visibleColumns = visibleColumns;
+
+ this.#button = document.getElementById(`${gridId}_columnConfigurationButton`) as HTMLButtonElement | null;
+ this.#button?.addEventListener(
+ "click",
+ promiseMutex(() => this.#showDialog()),
+ );
+ }
+
+ /**
+ * Returns the ids of the visible columns or `undefined` if the user
+ * did not configure the columns.
+ */
+ getVisibleColumns(): string[] | undefined {
+ return this.#visibleColumns !== undefined ? [...this.#visibleColumns] : undefined;
+ }
+
+ getQueryParameters(): [string, string][] {
+ if (this.#visibleColumns === undefined) {
+ return [];
+ }
+
+ return this.#visibleColumns.map((column) => ["columns[]", column]);
+ }
+
+ /**
+ * Reads the visible columns from the given search params and returns
+ * true if they differ from the current configuration.
+ */
+ updateFromSearchParams(params: URLSearchParams): boolean {
+ const columns: string[] = [];
+ params.forEach((value, key) => {
+ if (/^columns\[\d*\]$/.test(key)) {
+ columns.push(value);
+ }
+ });
+
+ const visibleColumns = columns.length > 0 ? columns : undefined;
+ const hasChanged = visibleColumns?.join(",") !== this.#visibleColumns?.join(",");
+ this.#visibleColumns = visibleColumns;
+
+ return hasChanged;
+ }
+
+ async #showDialog(): Promise {
+ const url = new URL(this.#button!.dataset.endpoint!);
+ this.#visibleColumns?.forEach((column, index) => {
+ url.searchParams.set(`columns[${index}]`, column);
+ });
+
+ const { ok, result } = await dialogFactory().usingFormBuilder().fromEndpoint(url.toString());
+
+ if (ok) {
+ // `null` represents the default configuration.
+ this.#visibleColumns = result ?? undefined;
+
+ this.dispatchEvent(new CustomEvent("grid-view:change"));
+ }
+ }
+}
+
+interface ColumnConfigurationEventMap {
+ "grid-view:change": CustomEvent;
+}
+
+// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
+export interface ColumnConfiguration extends EventTarget {
+ addEventListener: {
+ (
+ type: T,
+ listener: (this: ColumnConfiguration, ev: ColumnConfigurationEventMap[T]) => any,
+ options?: boolean | AddEventListenerOptions,
+ ): void;
+ } & HTMLElement["addEventListener"];
+}
+
+export default ColumnConfiguration;
diff --git a/ts/WoltLabSuite/Core/Component/GridView/Sorting.ts b/ts/WoltLabSuite/Core/Component/GridView/Sorting.ts
index b3ec2b67ec5..ee2f094b003 100644
--- a/ts/WoltLabSuite/Core/Component/GridView/Sorting.ts
+++ b/ts/WoltLabSuite/Core/Component/GridView/Sorting.ts
@@ -30,14 +30,17 @@ export class Sorting extends EventTarget {
this.#defaultSortOrder = defaultSortOrder;
this.#table = table;
- this.#table
- .querySelectorAll('.gridView__headerColumn[data-sortable="1"]')
- .forEach((element) => {
- const button = element.querySelector(".gridView__headerColumn__button");
- button?.addEventListener("click", () => {
- this.#sort(element.dataset.id!, element.dataset.defaultSortOrder ?? "ASC");
- });
- });
+ // The header columns can be replaced at runtime when the user configures
+ // the visible columns, therefore the click events are delegated.
+ this.#table.tHead?.addEventListener("click", (event) => {
+ const button = (event.target as HTMLElement).closest(".gridView__headerColumn__button");
+ const element = button?.closest('.gridView__headerColumn[data-sortable="1"]');
+ if (!element) {
+ return;
+ }
+
+ this.#sort(element.dataset.id!, element.dataset.defaultSortOrder ?? "ASC");
+ });
this.#renderActiveSorting();
}
diff --git a/ts/WoltLabSuite/Core/Component/GridView/State.ts b/ts/WoltLabSuite/Core/Component/GridView/State.ts
index 19d6c7f26cb..524b878120a 100644
--- a/ts/WoltLabSuite/Core/Component/GridView/State.ts
+++ b/ts/WoltLabSuite/Core/Component/GridView/State.ts
@@ -7,6 +7,7 @@
* @since 6.2
*/
+import ColumnConfiguration from "./ColumnConfiguration";
import Filter from "./Filter";
import Selection from "./Selection";
import Sorting from "./Sorting";
@@ -20,6 +21,7 @@ export const enum StateChangeCause {
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export class State extends EventTarget {
readonly #baseUrl: string;
+ readonly #columnConfiguration: ColumnConfiguration;
readonly #filter: Filter;
readonly #pagination: WoltlabCorePaginationElement;
readonly #selection: Selection;
@@ -37,6 +39,7 @@ export class State extends EventTarget {
sortOrder: string,
defaultSortField: string,
defaultSortOrder: string,
+ visibleColumns?: string[],
) {
super();
@@ -55,6 +58,14 @@ export class State extends EventTarget {
this.#switchPage(1, StateChangeCause.Change);
});
+ this.#columnConfiguration = new ColumnConfiguration(gridId, visibleColumns);
+ this.#columnConfiguration.addEventListener("grid-view:change", () => {
+ // Changing the visible columns does not affect the current page.
+ this.dispatchEvent(
+ new CustomEvent("grid-view:change", { detail: { source: StateChangeCause.Change, includeHeader: true } }),
+ );
+ });
+
this.#sorting = new Sorting(table, sortField, sortOrder, defaultSortField, defaultSortOrder);
this.#sorting.addEventListener("grid-view:change", () => {
this.#switchPage(1, StateChangeCause.Change);
@@ -97,6 +108,10 @@ export class State extends EventTarget {
return this.#filter.getActiveFilters();
}
+ getVisibleColumns(): string[] | undefined {
+ return this.#columnConfiguration.getVisibleColumns();
+ }
+
getSelectedIds(): number[] {
return this.#selection.getSelectedIds();
}
@@ -115,11 +130,11 @@ export class State extends EventTarget {
this.#updateGridViewFooter();
}
- #switchPage(pageNo: number, source: StateChangeCause): void {
+ #switchPage(pageNo: number, source: StateChangeCause, includeHeader = false): void {
this.#pagination.page = pageNo;
this.#pageNo = pageNo;
- this.dispatchEvent(new CustomEvent("grid-view:change", { detail: { source } }));
+ this.dispatchEvent(new CustomEvent("grid-view:change", { detail: { source, includeHeader } }));
}
#updateQueryString(): void {
@@ -156,6 +171,10 @@ export class State extends EventTarget {
parameters.push(parameter);
}
+ for (const parameter of this.#columnConfiguration.getQueryParameters()) {
+ parameters.push(parameter);
+ }
+
if (parameters.length > 0) {
url.search += url.search !== "" ? "&" : "?";
url.search += new URLSearchParams(parameters).toString();
@@ -183,8 +202,9 @@ export class State extends EventTarget {
this.#filter.updateFromSearchParams(url.searchParams);
this.#sorting.updateFromSearchParams(url.searchParams);
+ const columnsChanged = this.#columnConfiguration.updateFromSearchParams(url.searchParams);
- this.#switchPage(pageNo, StateChangeCause.History);
+ this.#switchPage(pageNo, StateChangeCause.History, columnsChanged);
}
#updateLastUrl(): void {
@@ -228,7 +248,7 @@ export class State extends EventTarget {
}
interface StateEventMap {
- "grid-view:change": CustomEvent<{ source: StateChangeCause }>;
+ "grid-view:change": CustomEvent<{ source: StateChangeCause; includeHeader: boolean }>;
"grid-view:get-bulk-interactions": CustomEvent<{ objectIds: number[] }>;
}
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/GridViews/GetRow.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/GridViews/GetRow.js
index 42cb9119822..d6a5f4ddac9 100644
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/GridViews/GetRow.js
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/GridViews/GetRow.js
@@ -10,7 +10,7 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "../Result"], fu
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.getRow = getRow;
- async function getRow(gridViewClass, objectId, filters, gridViewParameters) {
+ async function getRow(gridViewClass, objectId, filters, gridViewParameters, columns) {
const url = new URL(`${window.WSC_RPC_API_URL}core/grid-views/row`);
url.searchParams.set("gridView", gridViewClass);
url.searchParams.set("objectID", objectId.toString());
@@ -31,6 +31,11 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "../Result"], fu
}
});
}
+ if (columns) {
+ columns.forEach((column, index) => {
+ url.searchParams.set(`columns[${index}]`, column);
+ });
+ }
return (0, Result_1.fromInfallibleApiRequest)(() => {
return (0, Backend_1.prepareRequest)(url).get().allowCaching().disableLoadingIndicator().fetchAsJson();
});
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/GridViews/GetRows.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/GridViews/GetRows.js
index 0bf0267d866..357a645857b 100644
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/GridViews/GetRows.js
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/GridViews/GetRows.js
@@ -10,7 +10,7 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "../Result"], fu
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.getRows = getRows;
- async function getRows(gridViewClass, pageNo, sortField = "", sortOrder = "ASC", filters, gridViewParameters) {
+ async function getRows(gridViewClass, pageNo, sortField = "", sortOrder = "ASC", filters, gridViewParameters, columns, includeHeader = false) {
const url = new URL(`${window.WSC_RPC_API_URL}core/grid-views/rows`);
url.searchParams.set("gridView", gridViewClass);
url.searchParams.set("pageNo", pageNo.toString());
@@ -33,6 +33,14 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "../Result"], fu
}
});
}
+ if (columns) {
+ columns.forEach((column, index) => {
+ url.searchParams.set(`columns[${index}]`, column);
+ });
+ }
+ if (includeHeader) {
+ url.searchParams.set("includeHeader", "true");
+ }
return (0, Result_1.fromInfallibleApiRequest)(() => {
return (0, Backend_1.prepareRequest)(url).get().allowCaching().disableLoadingIndicator().fetchAsJson();
});
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView.js
index 8769b823181..9b8db3d3a2b 100644
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView.js
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView.js
@@ -20,18 +20,21 @@ define(["require", "exports", "tslib", "../Api/GridViews/GetRow", "../Api/GridVi
#noItemsNotice;
#bulkInteractionProviderClassName;
#gridViewParameters;
- constructor(gridId, gridClassName, pageNo, baseUrl = "", sortField = "", sortOrder = "ASC", defaultSortField = "", defaultSortOrder = "ASC", bulkInteractionProviderClassName, gridViewParameters) {
+ constructor(gridId, gridClassName, pageNo, baseUrl = "", sortField = "", sortOrder = "ASC", defaultSortField = "", defaultSortOrder = "ASC", bulkInteractionProviderClassName, gridViewParameters, visibleColumns) {
this.#gridClassName = gridClassName;
this.#table = document.getElementById(`${gridId}_table`);
this.#noItemsNotice = document.getElementById(`${gridId}_noItemsNotice`);
this.#bulkInteractionProviderClassName = bulkInteractionProviderClassName;
this.#gridViewParameters = gridViewParameters;
this.#initInteractions();
- this.#state = this.#setupState(gridId, pageNo, baseUrl, sortField, sortOrder, defaultSortField, defaultSortOrder);
+ this.#state = this.#setupState(gridId, pageNo, baseUrl, sortField, sortOrder, defaultSortField, defaultSortOrder, visibleColumns);
this.#initEventListeners();
}
- async #loadRows(cause) {
- const response = await (0, GetRows_1.getRows)(this.#gridClassName, this.#state.getPageNo(), this.#state.getSortField(), this.#state.getSortOrder(), this.#state.getActiveFilters(), this.#gridViewParameters);
+ async #loadRows(cause, includeHeader = false) {
+ const response = await (0, GetRows_1.getRows)(this.#gridClassName, this.#state.getPageNo(), this.#state.getSortField(), this.#state.getSortOrder(), this.#state.getActiveFilters(), this.#gridViewParameters, this.#state.getVisibleColumns(), includeHeader);
+ if (response.header !== undefined) {
+ this.#replaceHeaderColumns(response.header);
+ }
Util_1.default.setInnerHtml(this.#table.querySelector("tbody"), response.template);
this.#table.hidden = response.totalRows == 0;
this.#noItemsNotice.hidden = response.totalRows != 0;
@@ -39,12 +42,20 @@ define(["require", "exports", "tslib", "../Api/GridViews/GetRow", "../Api/GridVi
Listener_1.default.trigger();
}
async #refreshRow(row) {
- const { template } = await (0, GetRow_1.getRow)(this.#gridClassName, row.dataset.objectId, this.#state.getActiveFilters(), this.#gridViewParameters);
+ const { template } = await (0, GetRow_1.getRow)(this.#gridClassName, row.dataset.objectId, this.#state.getActiveFilters(), this.#gridViewParameters, this.#state.getVisibleColumns());
row.replaceWith(Util_1.default.createFragmentFromHtml(template));
this.#state.refreshSelection();
Listener_1.default.trigger();
this.#checkEmptyTable();
}
+ #replaceHeaderColumns(html) {
+ const headerRow = this.#table.querySelector("thead .gridView__headerRow");
+ headerRow.querySelectorAll("th[data-id]").forEach((element) => element.remove());
+ // The selection and action columns are not part of the configurable
+ // columns and must be preserved.
+ const actionColumn = headerRow.querySelector(".gridView__actionColumn");
+ headerRow.insertBefore(Util_1.default.createFragmentFromHtml(html), actionColumn);
+ }
#initInteractions() {
(0, Selector_1.wheneverFirstSeen)(`#${this.#table.id} tbody tr`, (row) => {
const containers = [row];
@@ -91,10 +102,10 @@ define(["require", "exports", "tslib", "../Api/GridViews/GetRow", "../Api/GridVi
this.#checkEmptyTable();
});
}
- #setupState(gridId, pageNo, baseUrl, sortField, sortOrder, defaultSortField, defaultSortOrder) {
- const state = new State_1.State(gridId, this.#table, pageNo, baseUrl, sortField, sortOrder, defaultSortField, defaultSortOrder);
+ #setupState(gridId, pageNo, baseUrl, sortField, sortOrder, defaultSortField, defaultSortOrder, visibleColumns) {
+ const state = new State_1.State(gridId, this.#table, pageNo, baseUrl, sortField, sortOrder, defaultSortField, defaultSortOrder, visibleColumns);
state.addEventListener("grid-view:change", (event) => {
- void this.#loadRows(event.detail.source);
+ void this.#loadRows(event.detail.source, event.detail.includeHeader);
});
state.addEventListener("grid-view:get-bulk-interactions", (0, PromiseMutex_1.promiseMutex)((event) => this.#loadBulkInteractions(event.detail.objectIds)));
return state;
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView/ColumnConfiguration.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView/ColumnConfiguration.js
new file mode 100644
index 00000000000..e5e7584338b
--- /dev/null
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView/ColumnConfiguration.js
@@ -0,0 +1,68 @@
+/**
+ * Handles the configuration of the visible columns of grid views. The
+ * configuration is not persisted, but it is part of the url.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.3
+ */
+define(["require", "exports", "../../Helper/PromiseMutex", "../Dialog"], function (require, exports, PromiseMutex_1, Dialog_1) {
+ "use strict";
+ Object.defineProperty(exports, "__esModule", { value: true });
+ exports.ColumnConfiguration = void 0;
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
+ class ColumnConfiguration extends EventTarget {
+ #button;
+ #visibleColumns;
+ constructor(gridId, visibleColumns) {
+ super();
+ this.#visibleColumns = visibleColumns;
+ this.#button = document.getElementById(`${gridId}_columnConfigurationButton`);
+ this.#button?.addEventListener("click", (0, PromiseMutex_1.promiseMutex)(() => this.#showDialog()));
+ }
+ /**
+ * Returns the ids of the visible columns or `undefined` if the user
+ * did not configure the columns.
+ */
+ getVisibleColumns() {
+ return this.#visibleColumns !== undefined ? [...this.#visibleColumns] : undefined;
+ }
+ getQueryParameters() {
+ if (this.#visibleColumns === undefined) {
+ return [];
+ }
+ return this.#visibleColumns.map((column) => ["columns[]", column]);
+ }
+ /**
+ * Reads the visible columns from the given search params and returns
+ * true if they differ from the current configuration.
+ */
+ updateFromSearchParams(params) {
+ const columns = [];
+ params.forEach((value, key) => {
+ if (/^columns\[\d*\]$/.test(key)) {
+ columns.push(value);
+ }
+ });
+ const visibleColumns = columns.length > 0 ? columns : undefined;
+ const hasChanged = visibleColumns?.join(",") !== this.#visibleColumns?.join(",");
+ this.#visibleColumns = visibleColumns;
+ return hasChanged;
+ }
+ async #showDialog() {
+ const url = new URL(this.#button.dataset.endpoint);
+ this.#visibleColumns?.forEach((column, index) => {
+ url.searchParams.set(`columns[${index}]`, column);
+ });
+ const { ok, result } = await (0, Dialog_1.dialogFactory)().usingFormBuilder().fromEndpoint(url.toString());
+ if (ok) {
+ // `null` represents the default configuration.
+ this.#visibleColumns = result ?? undefined;
+ this.dispatchEvent(new CustomEvent("grid-view:change"));
+ }
+ }
+ }
+ exports.ColumnConfiguration = ColumnConfiguration;
+ exports.default = ColumnConfiguration;
+});
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView/Sorting.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView/Sorting.js
index 5af86472ac7..aadb9f44750 100644
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView/Sorting.js
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView/Sorting.js
@@ -24,13 +24,15 @@ define(["require", "exports"], function (require, exports) {
this.#sortOrder = sortOrder;
this.#defaultSortOrder = defaultSortOrder;
this.#table = table;
- this.#table
- .querySelectorAll('.gridView__headerColumn[data-sortable="1"]')
- .forEach((element) => {
- const button = element.querySelector(".gridView__headerColumn__button");
- button?.addEventListener("click", () => {
- this.#sort(element.dataset.id, element.dataset.defaultSortOrder ?? "ASC");
- });
+ // The header columns can be replaced at runtime when the user configures
+ // the visible columns, therefore the click events are delegated.
+ this.#table.tHead?.addEventListener("click", (event) => {
+ const button = event.target.closest(".gridView__headerColumn__button");
+ const element = button?.closest('.gridView__headerColumn[data-sortable="1"]');
+ if (!element) {
+ return;
+ }
+ this.#sort(element.dataset.id, element.dataset.defaultSortOrder ?? "ASC");
});
this.#renderActiveSorting();
}
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView/State.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView/State.js
index 9b819548b4c..2dff0992abf 100644
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView/State.js
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView/State.js
@@ -6,16 +6,18 @@
* @license GNU Lesser General Public License
* @since 6.2
*/
-define(["require", "exports", "tslib", "./Filter", "./Selection", "./Sorting"], function (require, exports, tslib_1, Filter_1, Selection_1, Sorting_1) {
+define(["require", "exports", "tslib", "./ColumnConfiguration", "./Filter", "./Selection", "./Sorting"], function (require, exports, tslib_1, ColumnConfiguration_1, Filter_1, Selection_1, Sorting_1) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.State = void 0;
+ ColumnConfiguration_1 = tslib_1.__importDefault(ColumnConfiguration_1);
Filter_1 = tslib_1.__importDefault(Filter_1);
Selection_1 = tslib_1.__importDefault(Selection_1);
Sorting_1 = tslib_1.__importDefault(Sorting_1);
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
class State extends EventTarget {
#baseUrl;
+ #columnConfiguration;
#filter;
#pagination;
#selection;
@@ -23,7 +25,7 @@ define(["require", "exports", "tslib", "./Filter", "./Selection", "./Sorting"],
#gridViewFooter;
#lastUrl;
#pageNo;
- constructor(gridId, table, pageNo, baseUrl, sortField, sortOrder, defaultSortField, defaultSortOrder) {
+ constructor(gridId, table, pageNo, baseUrl, sortField, sortOrder, defaultSortField, defaultSortOrder, visibleColumns) {
super();
this.#baseUrl = baseUrl;
this.#pageNo = pageNo;
@@ -36,6 +38,11 @@ define(["require", "exports", "tslib", "./Filter", "./Selection", "./Sorting"],
this.#filter.addEventListener("grid-view:change", () => {
this.#switchPage(1, 0 /* StateChangeCause.Change */);
});
+ this.#columnConfiguration = new ColumnConfiguration_1.default(gridId, visibleColumns);
+ this.#columnConfiguration.addEventListener("grid-view:change", () => {
+ // Changing the visible columns does not affect the current page.
+ this.dispatchEvent(new CustomEvent("grid-view:change", { detail: { source: 0 /* StateChangeCause.Change */, includeHeader: true } }));
+ });
this.#sorting = new Sorting_1.default(table, sortField, sortOrder, defaultSortField, defaultSortOrder);
this.#sorting.addEventListener("grid-view:change", () => {
this.#switchPage(1, 0 /* StateChangeCause.Change */);
@@ -68,6 +75,9 @@ define(["require", "exports", "tslib", "./Filter", "./Selection", "./Sorting"],
getActiveFilters() {
return this.#filter.getActiveFilters();
}
+ getVisibleColumns() {
+ return this.#columnConfiguration.getVisibleColumns();
+ }
getSelectedIds() {
return this.#selection.getSelectedIds();
}
@@ -82,10 +92,10 @@ define(["require", "exports", "tslib", "./Filter", "./Selection", "./Sorting"],
}
this.#updateGridViewFooter();
}
- #switchPage(pageNo, source) {
+ #switchPage(pageNo, source, includeHeader = false) {
this.#pagination.page = pageNo;
this.#pageNo = pageNo;
- this.dispatchEvent(new CustomEvent("grid-view:change", { detail: { source } }));
+ this.dispatchEvent(new CustomEvent("grid-view:change", { detail: { source, includeHeader } }));
}
#updateQueryString() {
if (!this.#baseUrl) {
@@ -113,6 +123,9 @@ define(["require", "exports", "tslib", "./Filter", "./Selection", "./Sorting"],
for (const parameter of this.#filter.getQueryParameters()) {
parameters.push(parameter);
}
+ for (const parameter of this.#columnConfiguration.getQueryParameters()) {
+ parameters.push(parameter);
+ }
if (parameters.length > 0) {
url.search += url.search !== "" ? "&" : "?";
url.search += new URLSearchParams(parameters).toString();
@@ -134,7 +147,8 @@ define(["require", "exports", "tslib", "./Filter", "./Selection", "./Sorting"],
}
this.#filter.updateFromSearchParams(url.searchParams);
this.#sorting.updateFromSearchParams(url.searchParams);
- this.#switchPage(pageNo, 1 /* StateChangeCause.History */);
+ const columnsChanged = this.#columnConfiguration.updateFromSearchParams(url.searchParams);
+ this.#switchPage(pageNo, 1 /* StateChangeCause.History */, columnsChanged);
}
#updateLastUrl() {
if (!this.#baseUrl) {
diff --git a/wcfsetup/install/files/lib/action/GridViewColumnConfigurationAction.class.php b/wcfsetup/install/files/lib/action/GridViewColumnConfigurationAction.class.php
new file mode 100644
index 00000000000..dfbb03c0d4e
--- /dev/null
+++ b/wcfsetup/install/files/lib/action/GridViewColumnConfigurationAction.class.php
@@ -0,0 +1,139 @@
+
+ * @since 6.3
+ */
+final class GridViewColumnConfigurationAction implements RequestHandlerInterface
+{
+ #[\Override]
+ public function handle(ServerRequestInterface $request): ResponseInterface
+ {
+ $parameters = Helper::mapQueryParameters(
+ $request->getQueryParams(),
+ <<<'EOT'
+ array {
+ gridView: string,
+ columns: list,
+ gridViewParameters: array
+ }
+ EOT
+ );
+
+ if (!\is_subclass_of($parameters['gridView'], AbstractGridView::class)) {
+ throw new UserInputException('gridView', 'invalid');
+ }
+
+ try {
+ /** @var AbstractGridView> $view */
+ $view = new $parameters['gridView'](...$parameters['gridViewParameters']);
+ } catch (\ArgumentCountError | \TypeError $e) {
+ if (\ENABLE_DEBUG_MODE !== 0) {
+ throw $e;
+ } else {
+ throw new IllegalLinkException();
+ }
+ }
+
+ if (!$view->isAccessible()) {
+ throw new PermissionDeniedException();
+ }
+
+ if (!$view->isColumnConfigurable()) {
+ throw new IllegalLinkException();
+ }
+
+ if ($parameters['columns'] !== []) {
+ $view->setVisibleColumnIDs($parameters['columns']);
+ }
+
+ $form = $this->getForm($view);
+
+ if ($request->getMethod() === 'GET') {
+ return $form->toResponse();
+ } elseif ($request->getMethod() === 'POST') {
+ $response = $form->validateRequest($request);
+ if ($response !== null) {
+ return $response;
+ }
+
+ $selectedColumnIDs = $form->getData()['columns'] ?? [];
+
+ // The title column is always visible, including it ensures that
+ // the list of visible columns is never empty.
+ $columns = [];
+ $isDefault = true;
+ foreach ($view->getColumns() as $column) {
+ $isVisible = $column->isTitleColumn() || \in_array($column->getID(), $selectedColumnIDs, true);
+ if ($isVisible) {
+ $columns[] = $column->getID();
+ }
+
+ if (!$column->isTitleColumn() && $isVisible === $column->isHidden()) {
+ $isDefault = false;
+ }
+ }
+
+ return new JsonResponse([
+ // `null` represents the default configuration which is
+ // omitted from the url.
+ 'result' => $isDefault ? null : $columns,
+ ]);
+ } else {
+ throw new \LogicException('Unreachable');
+ }
+ }
+
+ /**
+ * @param AbstractGridView> $gridView
+ */
+ private function getForm(AbstractGridView $gridView): Psr15DialogForm
+ {
+ $form = new Psr15DialogForm(
+ static::class,
+ WCF::getLanguage()->get('wcf.global.gridView.columns')
+ );
+
+ $options = [];
+ $visibleColumnIDs = [];
+ foreach ($gridView->getConfigurableColumns() as $column) {
+ $options[$column->getID()] = $column->getLabel();
+
+ if ($gridView->isColumnVisible($column)) {
+ $visibleColumnIDs[] = $column->getID();
+ }
+ }
+
+ $form->appendChild(
+ MultipleSelectionFormField::create('columns')
+ ->options($options, false, false)
+ ->value($visibleColumnIDs)
+ );
+
+ $form->markRequiredFields(false);
+ $form->build();
+
+ return $form;
+ }
+}
diff --git a/wcfsetup/install/files/lib/page/AbstractGridViewPage.class.php b/wcfsetup/install/files/lib/page/AbstractGridViewPage.class.php
index 1e8ddd8ae45..9ab826d780c 100644
--- a/wcfsetup/install/files/lib/page/AbstractGridViewPage.class.php
+++ b/wcfsetup/install/files/lib/page/AbstractGridViewPage.class.php
@@ -33,6 +33,11 @@ abstract class AbstractGridViewPage extends AbstractPage
*/
protected array $filters = [];
+ /**
+ * @var list
+ */
+ protected array $columns = [];
+
#[\Override]
public function readParameters()
{
@@ -50,6 +55,9 @@ public function readParameters()
if (isset($_REQUEST['filters']) && \is_array($_REQUEST['filters'])) {
$this->filters = $_REQUEST['filters'];
}
+ if (isset($_REQUEST['columns']) && \is_array($_REQUEST['columns'])) {
+ $this->columns = \array_values(\array_filter($_REQUEST['columns'], '\is_string'));
+ }
}
#[\Override]
@@ -98,6 +106,9 @@ protected function initGridView(): void
if ($this->filters !== []) {
$this->gridView->setActiveFilters($this->filters);
}
+ if ($this->columns !== []) {
+ $this->gridView->setVisibleColumnIDs($this->columns);
+ }
if ($this->pageNo > 1) {
$this->gridView->setPageNo($this->pageNo);
}
@@ -122,6 +133,7 @@ protected function getHeadContent(): string
// example when the grid view has no sortable columns at all.
$sortField = $this->gridView->getSortField();
$sortOrder = $sortField !== '' ? $this->gridView->getSortOrder() : '';
+ $columns = $this->gridView->getVisibleColumnIDs();
if ($this->gridView->getPageNo() < $this->gridView->countPages()) {
$linkTags[] = \sprintf(
' ',
@@ -132,6 +144,7 @@ protected function getHeadContent(): string
'pageNo' => $this->gridView->getPageNo() + 1,
'sortField' => $sortField ?: null,
'sortOrder' => $sortOrder ?: null,
+ 'columns' => $columns,
]
))
)
@@ -148,6 +161,7 @@ protected function getHeadContent(): string
'pageNo' => $this->gridView->getPageNo() !== 2 ? $this->gridView->getPageNo() - 1 : null,
'sortField' => $sortField ?: null,
'sortOrder' => $sortOrder ?: null,
+ 'columns' => $columns,
]
))
)
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/gridViews/GetRow.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/gridViews/GetRow.class.php
index 5a9ff2c2841..ffe995f562f 100644
--- a/wcfsetup/install/files/lib/system/endpoint/controller/core/gridViews/GetRow.class.php
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/gridViews/GetRow.class.php
@@ -44,6 +44,10 @@ public function __invoke(ServerRequestInterface $request, array $variables): Res
$view->setActiveFilters($parameters->filters);
}
+ if ($parameters->columns !== []) {
+ $view->setVisibleColumnIDs($parameters->columns);
+ }
+
$view->setObjectIDFilter($parameters->objectID);
return new JsonResponse([
@@ -63,5 +67,7 @@ public function __construct(
public readonly array $filters,
/** @var array */
public readonly array $gridViewParameters,
+ /** @var list */
+ public readonly array $columns = [],
) {}
}
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/gridViews/GetRows.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/gridViews/GetRows.class.php
index 7d53cc092ca..129eacc1473 100644
--- a/wcfsetup/install/files/lib/system/endpoint/controller/core/gridViews/GetRows.class.php
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/gridViews/GetRows.class.php
@@ -52,12 +52,22 @@ public function __invoke(ServerRequestInterface $request, array $variables): Res
$view->setActiveFilters($parameters->filters);
}
- return new JsonResponse([
+ if ($parameters->columns !== []) {
+ $view->setVisibleColumnIDs($parameters->columns);
+ }
+
+ $response = [
'template' => $view->renderRows(),
'pages' => $view->countPages(),
'totalRows' => $view->countRows(),
'filterLabels' => $this->getFilterLabels($view, \array_keys($parameters->filters)),
- ]);
+ ];
+
+ if ($parameters->includeHeader) {
+ $response['header'] = $view->renderHeaderColumns();
+ }
+
+ return new JsonResponse($response);
}
/**
@@ -89,5 +99,8 @@ public function __construct(
public readonly array $filters,
/** @var array */
public readonly array $gridViewParameters,
+ /** @var list */
+ public readonly array $columns = [],
+ public readonly bool $includeHeader = false,
) {}
}
diff --git a/wcfsetup/install/files/lib/system/gridView/AbstractGridView.class.php b/wcfsetup/install/files/lib/system/gridView/AbstractGridView.class.php
index 0c00d5f1087..524e2965e87 100644
--- a/wcfsetup/install/files/lib/system/gridView/AbstractGridView.class.php
+++ b/wcfsetup/install/files/lib/system/gridView/AbstractGridView.class.php
@@ -3,6 +3,7 @@
namespace wcf\system\gridView;
use wcf\action\ApiAction;
+use wcf\action\GridViewColumnConfigurationAction;
use wcf\action\GridViewFilterAction;
use wcf\data\DatabaseObject;
use wcf\data\DatabaseObjectList;
@@ -66,6 +67,12 @@ abstract class AbstractGridView
private ?IBulkInteractionProvider $bulkInteractionProvider = null;
private InteractionContextMenuComponent $interactionContextMenuComponent;
private string $markAsReadEndpoint = '';
+ private bool $columnConfigurable = false;
+
+ /**
+ * @var ?list
+ */
+ private ?array $visibleColumnIDs = null;
/**
* @var array
@@ -174,7 +181,109 @@ public function getColumns(): array
*/
public function getVisibleColumns(): array
{
- return \array_filter($this->getColumns(), fn($column) => !$column->isHidden());
+ return \array_filter($this->getColumns(), fn($column) => $this->isColumnVisible($column));
+ }
+
+ /**
+ * Returns true if the given column is visible. The title column is always
+ * visible, the visibility of the other columns can be configured by the
+ * user if this grid view is column configurable.
+ *
+ * @since 6.3
+ */
+ public function isColumnVisible(GridViewColumn $column): bool
+ {
+ if ($column->isTitleColumn()) {
+ return true;
+ }
+
+ if ($this->visibleColumnIDs !== null) {
+ return \in_array($column->getID(), $this->visibleColumnIDs, true);
+ }
+
+ return !$column->isHidden();
+ }
+
+ /**
+ * Returns all columns whose visibility can be configured by the user.
+ *
+ * @return GridViewColumn[]
+ * @since 6.3
+ */
+ public function getConfigurableColumns(): array
+ {
+ $this->init();
+
+ return \array_filter($this->getColumns(), static fn($column) => !$column->isTitleColumn());
+ }
+
+ /**
+ * Sets whether the user can configure the visible columns of this grid view.
+ *
+ * @since 6.3
+ */
+ public function setColumnConfigurable(bool $columnConfigurable = true): void
+ {
+ $this->columnConfigurable = $columnConfigurable;
+ }
+
+ /**
+ * Returns true if the user can configure the visible columns of this grid view.
+ *
+ * @since 6.3
+ */
+ public function isColumnConfigurable(): bool
+ {
+ return $this->columnConfigurable;
+ }
+
+ /**
+ * Sets the ids of the columns that are visible instead of the default
+ * visibility of the columns. Has no effect unless this grid view is
+ * column configurable.
+ *
+ * @param list $columnIDs
+ * @since 6.3
+ */
+ public function setVisibleColumnIDs(array $columnIDs): void
+ {
+ if (!$this->isColumnConfigurable()) {
+ return;
+ }
+
+ $this->visibleColumnIDs = $columnIDs;
+ }
+
+ /**
+ * Returns the ids of the visible columns or null if the default
+ * visibility of the columns is used.
+ *
+ * @return ?list
+ * @since 6.3
+ */
+ public function getVisibleColumnIDs(): ?array
+ {
+ if ($this->visibleColumnIDs === null) {
+ return null;
+ }
+
+ return \array_values(\array_map(
+ static fn($column) => $column->getID(),
+ $this->getVisibleColumns()
+ ));
+ }
+
+ /**
+ * Returns the endpoint for the column configuration action.
+ *
+ * @since 6.3
+ */
+ public function getColumnConfigurationEndpoint(): string
+ {
+ return LinkHandler::getInstance()->getControllerLink(
+ GridViewColumnConfigurationAction::class,
+ ['gridView' => \get_class($this), 'gridViewParameters' => $this->getParameters()]
+ );
}
/**
@@ -297,6 +406,18 @@ public function render(): string
return WCF::getTPL()->render('wcf', 'shared_gridView', ['view' => $this]);
}
+ /**
+ * Renders the header columns and returns the HTML code.
+ *
+ * @since 6.3
+ */
+ public function renderHeaderColumns(): string
+ {
+ $this->init();
+
+ return WCF::getTPL()->render('wcf', 'shared_gridViewHeaderColumns', ['view' => $this]);
+ }
+
/**
* Renders the rows and returns the HTML code.
*/
diff --git a/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php
index 107bf847151..d7e8068760b 100644
--- a/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php
+++ b/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php
@@ -149,6 +149,7 @@ public function __construct()
)
);
+ $this->setColumnConfigurable();
$this->setDefaultSortField('registrationDate');
$this->setDefaultSortOrder('DESC');
$this->setRowsPerPage(50);
diff --git a/wcfsetup/install/files/style/ui/gridView.scss b/wcfsetup/install/files/style/ui/gridView.scss
index b71ece82518..57d664af9c0 100644
--- a/wcfsetup/install/files/style/ui/gridView.scss
+++ b/wcfsetup/install/files/style/ui/gridView.scss
@@ -237,7 +237,9 @@ html.iOS {
}
.gridView__filterButton {
+ display: flex;
flex: 0 0 auto;
+ gap: 5px;
}
.gridView__footer {
diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml
index 15223ce6115..aa9ab04b86c 100644
--- a/wcfsetup/install/lang/de.xml
+++ b/wcfsetup/install/lang/de.xml
@@ -3938,6 +3938,7 @@ Dateianhänge:
+
diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml
index c15daff89f1..f38092bcdd2 100644
--- a/wcfsetup/install/lang/en.xml
+++ b/wcfsetup/install/lang/en.xml
@@ -3884,6 +3884,7 @@ Attachments:
+
From f7052a7375e9ab27122a9f72475c7ba6634a6efd Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Mon, 5 Oct 2026 17:01:28 +0200
Subject: [PATCH 029/115] Add hidden user option columns to `UserGridView`
---
.../gridView/admin/UserGridView.class.php | 53 +++++++++++++++++++
1 file changed, 53 insertions(+)
diff --git a/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php
index d7e8068760b..f5739fb1892 100644
--- a/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php
+++ b/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php
@@ -121,6 +121,7 @@ public function __construct()
->filter(IntegerFilter::class)
->sortable(defaultSortOrder: 'DESC')
->hidden(),
+ ...$this->getUserOptionColumns(),
]);
$this->addAvailableFilters([
@@ -289,6 +290,58 @@ public function render(mixed $value, DatabaseObject $row): string
};
}
+ /**
+ * Returns the hidden columns for the profile options, except for messages.
+ * Unlike the members list, administrators can see every option regardless
+ * of its visibility.
+ *
+ * @return list
+ */
+ private function getUserOptionColumns(): array
+ {
+ $options = $this->getUserOptionsByCategory('profile', static function (UserOption $option) {
+ return $option->optionType !== 'aboutMe'
+ && $option->optionType !== 'message'
+ && $option->validateOptions()
+ && $option->validatePermissions();
+ });
+
+ $columns = [];
+ foreach (\array_merge(...\array_values($options)) as $option) {
+ $columns[] = GridViewColumn::for($option->getIdentifier())
+ ->label('wcf.user.option.' . $option->optionName)
+ ->renderer($this->getUserOptionRenderer($option))
+ ->unsafeDisableEncoding()
+ ->sortable(sortByDatabaseColumn: 'user_option_value.' . $option->getIdentifier())
+ ->hidden();
+ }
+
+ return $columns;
+ }
+
+ private function getUserOptionRenderer(UserOption $option): DefaultColumnRenderer
+ {
+ return new class($option) extends DefaultColumnRenderer {
+ public function __construct(private readonly UserOption $option) {}
+
+ #[\Override]
+ public function render(mixed $value, DatabaseObject $row): string
+ {
+ // The value is not encoded, because the formatters take care of it.
+ $value = (string)$value;
+ if ($value === '' || $value === '0000-00-00') {
+ return '';
+ }
+
+ return $this->option->getFormOption()->getFormatter()->format(
+ $value,
+ WCF::getLanguage()->languageID,
+ $this->option->getConfiguration()
+ );
+ }
+ };
+ }
+
private function getGroupFilter(): SelectFilter
{
$options = [];
From 65e6bcbb4b90955b4571565823c2611a8d980ea4 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Mon, 5 Oct 2026 19:40:20 +0200
Subject: [PATCH 030/115] Fix sorting `UserGridView` by user option columns
---
.../system/gridView/admin/UserGridView.class.php | 13 ++++++++++++-
1 file changed, 12 insertions(+), 1 deletion(-)
diff --git a/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php
index f5739fb1892..ed4a04fb612 100644
--- a/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php
+++ b/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php
@@ -181,7 +181,18 @@ public function getRows(): array
#[\Override]
protected function createObjectList(): UserProfileList
{
- return new UserProfileList();
+ $list = new UserProfileList();
+
+ // `UserList` only joins the user option values when reading the objects,
+ // sorting by an option requires them when reading the ids as well.
+ $sortColumn = $this->getColumn($this->getSortField());
+ if ($sortColumn !== null && \str_starts_with($sortColumn->getSortByDatabaseColumn(), 'user_option_value.')) {
+ $list->sqlConditionJoins .= "
+ LEFT JOIN wcf1_user_option_value user_option_value
+ ON user_option_value.userID = user_table.userID";
+ }
+
+ return $list;
}
#[\Override]
From c4f19b425bbbf6fcf8f52ec52bd6d24664de96c0 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Tue, 6 Oct 2026 13:11:49 +0200
Subject: [PATCH 031/115] Move avatar, rank, online status and privacy checks
from `UserProfile` to `User` via `UserCollection`
---
.../files/lib/acp/form/UserEditForm.class.php | 3 +-
.../files/lib/data/user/User.class.php | 85 +++++++-
.../lib/data/user/UserCollection.class.php | 191 ++++++++++++++++++
.../files/lib/data/user/UserProfile.class.php | 106 +---------
.../event/user/UserAvatarLoading.class.php | 35 ++++
.../user/UserPrivacySettingChecking.class.php | 36 ++++
.../gridView/admin/UserGridView.class.php | 30 +--
.../UserContentRemoveInteraction.class.php | 4 +-
.../admin/UserInteractions.class.php | 42 ++--
.../bulk/admin/UserBulkInteractions.class.php | 42 ++--
...UserContentRemoveBulkInteraction.class.php | 4 +-
11 files changed, 408 insertions(+), 170 deletions(-)
create mode 100644 wcfsetup/install/files/lib/data/user/UserCollection.class.php
create mode 100644 wcfsetup/install/files/lib/event/user/UserAvatarLoading.class.php
create mode 100644 wcfsetup/install/files/lib/event/user/UserPrivacySettingChecking.class.php
diff --git a/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php b/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php
index d97d5596bcd..ecceef952f3 100755
--- a/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php
@@ -10,7 +10,6 @@
use wcf\data\user\User;
use wcf\data\user\UserAction;
use wcf\data\user\UserEditor;
-use wcf\data\user\UserProfile;
use wcf\data\user\UserProfileAction;
use wcf\form\AbstractForm;
use wcf\system\cache\runtime\FileRuntimeCache;
@@ -364,7 +363,7 @@ public function assignVariables()
WCF::getTPL()->assign([
'interactionContextMenu' => StandaloneInteractionContextMenuComponent::forContentHeaderButton(
new UserInteractions(),
- new UserProfile($this->user->getDecoratedObject()),
+ $this->user->getDecoratedObject(),
LinkHandler::getInstance()->getControllerLink(UserListPage::class)
),
'userID' => $this->user->userID,
diff --git a/wcfsetup/install/files/lib/data/user/User.class.php b/wcfsetup/install/files/lib/data/user/User.class.php
index ed1e2ba0e75..e129faf8a07 100644
--- a/wcfsetup/install/files/lib/data/user/User.class.php
+++ b/wcfsetup/install/files/lib/data/user/User.class.php
@@ -2,14 +2,19 @@
namespace wcf\data\user;
+use wcf\data\CollectionDatabaseObject;
use wcf\data\DatabaseObject;
use wcf\data\IPopoverObject;
use wcf\data\IUserContent;
use wcf\data\language\Language;
+use wcf\data\user\avatar\AvatarDecorator;
use wcf\data\user\group\UserGroup;
use wcf\data\user\option\UserOption;
+use wcf\data\user\rank\UserRank;
+use wcf\event\user\UserPrivacySettingChecking;
use wcf\page\UserPage;
use wcf\system\cache\builder\UserOptionCacheBuilder;
+use wcf\system\event\EventHandler;
use wcf\system\language\LanguageFactory;
use wcf\system\request\IRouteController;
use wcf\system\request\LinkHandler;
@@ -76,8 +81,10 @@
* @property-read 0|1 $multifactorActive is `1` if the use has enabled a second factor, otherwise `0`
* @property-read int $trophyPoints total number of user's trophies in active categories
* @property-read ?string $timezone
+ *
+ * @extends CollectionDatabaseObject
*/
-final class User extends DatabaseObject implements IPopoverObject, IRouteController, IUserContent, \Stringable
+final class User extends CollectionDatabaseObject implements IPopoverObject, IRouteController, IUserContent, \Stringable
{
/**
* list of group ids
@@ -107,6 +114,15 @@ final class User extends DatabaseObject implements IPopoverObject, IRouteControl
*/
protected static ?array $userOptions = null;
+ /**
+ * access levels of the privacy settings
+ * @since 6.3
+ */
+ public const ACCESS_EVERYONE = 0;
+ public const ACCESS_REGISTERED = 1;
+ public const ACCESS_FOLLOWING = 2;
+ public const ACCESS_NOBODY = 3;
+
const REGISTER_ACTIVATION_NONE = 0;
const REGISTER_ACTIVATION_USER = 1;
@@ -262,6 +278,73 @@ public function getLanguageIDs(): array
return $this->languageIDs;
}
+ /**
+ * Returns the avatar of this user.
+ *
+ * @since 6.3
+ */
+ public function getAvatar(): AvatarDecorator
+ {
+ return $this->getCollection()->getAvatar($this);
+ }
+
+ /**
+ * Returns the rank of this user or null if they have no rank.
+ *
+ * @since 6.3
+ */
+ public function getRank(): ?UserRank
+ {
+ return $this->getCollection()->getRank($this);
+ }
+
+ /**
+ * Returns the custom user title or the title of the rank of this user.
+ *
+ * @since 6.3
+ */
+ public function getUserTitle(): string
+ {
+ return $this->getCollection()->getUserTitle($this);
+ }
+
+ /**
+ * Returns true if this user is online and the active user can see the
+ * online status.
+ *
+ * @since 6.3
+ */
+ public function isOnline(): bool
+ {
+ return $this->getCollection()->isOnline($this);
+ }
+
+ /**
+ * Returns true if the user with the given id can access the data protected
+ * by the privacy setting `$name` of this user. Checks the access of the
+ * active user if no user id is given.
+ *
+ * @since 6.3
+ */
+ public function isAccessible(string $name, ?int $userID = null): bool
+ {
+ $userID ??= WCF::getUser()->userID;
+
+ $accessible = match ((int)$this->{$name}) {
+ self::ACCESS_EVERYONE => true,
+ self::ACCESS_REGISTERED => $userID !== 0,
+ self::ACCESS_FOLLOWING => $userID !== 0
+ && ($userID === $this->userID || $this->getCollection()->isFollowing($this, $userID)),
+ self::ACCESS_NOBODY => false,
+ default => true,
+ };
+
+ $event = new UserPrivacySettingChecking($this, $name, $userID, $accessible);
+ EventHandler::getInstance()->fire($event);
+
+ return $event->isAccessible();
+ }
+
/**
* Returns the value of the user option with the given name.
*
diff --git a/wcfsetup/install/files/lib/data/user/UserCollection.class.php b/wcfsetup/install/files/lib/data/user/UserCollection.class.php
new file mode 100644
index 00000000000..cec2d9ed36f
--- /dev/null
+++ b/wcfsetup/install/files/lib/data/user/UserCollection.class.php
@@ -0,0 +1,191 @@
+
+ * @since 6.3
+ *
+ * @extends DatabaseObjectCollection
+ */
+class UserCollection extends DatabaseObjectCollection
+{
+ /**
+ * @var array
+ */
+ private array $avatars = [];
+
+ /**
+ * @var array
+ */
+ private array $sessionLastActivityTimes;
+
+ /**
+ * ids of the users of this collection that follow a user, grouped by
+ * the id of the followed user
+ * @var array>
+ */
+ private array $followerUserIDs = [];
+
+ public function getAvatar(User $user): AvatarDecorator
+ {
+ if (!isset($this->avatars[$user->userID])) {
+ $avatar = null;
+ if ($user->disableAvatar === 0 && $this->canSeeAvatar($user)) {
+ if ($user->avatarPathname !== null) {
+ $avatar = new StaticAvatar($user->avatarPathname);
+ } else {
+ $event = new UserAvatarLoading($user);
+ EventHandler::getInstance()->fire($event);
+
+ $avatar = $event->getAvatar();
+ }
+ }
+
+ $this->avatars[$user->userID] = new AvatarDecorator(
+ $avatar ?? new DefaultAvatar($user->username ?: '')
+ );
+ }
+
+ return $this->avatars[$user->userID];
+ }
+
+ private function canSeeAvatar(User $user): bool
+ {
+ return WCF::getUser()->userID === $user->userID
+ || WCF::getSession()->hasPermission('user.profile.avatar.canSeeAvatars')
+ || (($pending = WCF::getSession()->getPendingUserChange()) !== null && $pending->userID === $user->userID);
+ }
+
+ public function getRank(User $user): ?UserRank
+ {
+ if (\MODULE_USER_RANK === 0 || $user->rankID === null) {
+ return null;
+ }
+
+ return UserRankCacheBuilder::getInstance()->getRank($user->rankID);
+ }
+
+ public function getUserTitle(User $user): string
+ {
+ if ($user->userTitle !== '') {
+ return $user->userTitle;
+ }
+
+ $rank = $this->getRank($user);
+ if ($rank !== null && $rank->showTitle()) {
+ return $rank->getTitle();
+ }
+
+ return '';
+ }
+
+ /**
+ * Returns the time of the last activity, including the activity in a
+ * session that has not yet been written to the user.
+ */
+ public function getLastActivityTime(User $user): int
+ {
+ $this->loadSessionLastActivityTimes();
+
+ return \max($user->lastActivityTime, $this->sessionLastActivityTimes[$user->userID] ?? 0);
+ }
+
+ public function isOnline(User $user): bool
+ {
+ return $this->getLastActivityTime($user) > (\TIME_NOW - \USER_ONLINE_TIMEOUT)
+ && $this->canViewOnlineStatus($user);
+ }
+
+ public function canViewOnlineStatus(User $user): bool
+ {
+ return WCF::getUser()->userID === $user->userID
+ || WCF::getSession()->hasPermission('admin.user.canViewInvisible')
+ || $user->isAccessible('canViewOnlineStatus');
+ }
+
+ /**
+ * Returns true if the given user follows the user with the given id.
+ */
+ public function isFollowing(User $user, int $followUserID): bool
+ {
+ $this->loadFollowerUserIDs($followUserID);
+
+ return isset($this->followerUserIDs[$followUserID][$user->userID]);
+ }
+
+ private function loadSessionLastActivityTimes(): void
+ {
+ if (isset($this->sessionLastActivityTimes)) {
+ return;
+ }
+
+ $this->sessionLastActivityTimes = [];
+
+ $userIDs = $this->getRegisteredUserIDs();
+ if ($userIDs === []) {
+ return;
+ }
+
+ $conditionBuilder = new PreparedStatementConditionBuilder();
+ $conditionBuilder->add('userID IN (?)', [$userIDs]);
+
+ $sql = "SELECT userID, MAX(lastActivityTime) AS lastActivityTime
+ FROM wcf1_session
+ {$conditionBuilder}
+ GROUP BY userID";
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute($conditionBuilder->getParameters());
+ $this->sessionLastActivityTimes = $statement->fetchMap('userID', 'lastActivityTime');
+ }
+
+ private function loadFollowerUserIDs(int $followUserID): void
+ {
+ if (isset($this->followerUserIDs[$followUserID])) {
+ return;
+ }
+
+ $this->followerUserIDs[$followUserID] = [];
+
+ $userIDs = $this->getRegisteredUserIDs();
+ if ($userIDs === [] || $followUserID === 0) {
+ return;
+ }
+
+ $conditionBuilder = new PreparedStatementConditionBuilder();
+ $conditionBuilder->add('followUserID = ?', [$followUserID]);
+ $conditionBuilder->add('userID IN (?)', [$userIDs]);
+
+ $sql = "SELECT userID
+ FROM wcf1_user_follow
+ {$conditionBuilder}";
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute($conditionBuilder->getParameters());
+ foreach ($statement->fetchAll(\PDO::FETCH_COLUMN) as $userID) {
+ $this->followerUserIDs[$followUserID][$userID] = $userID;
+ }
+ }
+
+ /**
+ * @return int[]
+ */
+ private function getRegisteredUserIDs(): array
+ {
+ return \array_values(\array_filter($this->getObjectIDs(), static fn($userID) => $userID !== 0));
+ }
+}
diff --git a/wcfsetup/install/files/lib/data/user/UserProfile.class.php b/wcfsetup/install/files/lib/data/user/UserProfile.class.php
index a01895717f4..8613f6d30ca 100644
--- a/wcfsetup/install/files/lib/data/user/UserProfile.class.php
+++ b/wcfsetup/install/files/lib/data/user/UserProfile.class.php
@@ -8,9 +8,6 @@
use wcf\data\trophy\Trophy;
use wcf\data\trophy\TrophyCache;
use wcf\data\user\avatar\AvatarDecorator;
-use wcf\data\user\avatar\DefaultAvatar;
-use wcf\data\user\avatar\IUserAvatar;
-use wcf\data\user\avatar\StaticAvatar;
use wcf\data\user\cover\photo\DefaultUserCoverPhoto;
use wcf\data\user\cover\photo\GeneratedUserCoverPhoto;
use wcf\data\user\cover\photo\IUserCoverPhoto;
@@ -27,8 +24,6 @@
use wcf\system\cache\runtime\UserProfileRuntimeCache;
use wcf\system\database\util\PreparedStatementConditionBuilder;
use wcf\system\email\Mailbox;
-use wcf\system\event\EventHandler;
-use wcf\system\exception\ImplementationException;
use wcf\system\user\signature\SignatureCache;
use wcf\system\user\storage\UserStorageHandler;
use wcf\system\user\UserProfileHandler;
@@ -122,13 +117,13 @@ class UserProfile extends DatabaseObjectDecorator implements ITitledLinkObject,
const GENDER_OTHER = 3;
- const ACCESS_EVERYONE = 0;
+ const ACCESS_EVERYONE = User::ACCESS_EVERYONE;
- const ACCESS_REGISTERED = 1;
+ const ACCESS_REGISTERED = User::ACCESS_REGISTERED;
- const ACCESS_FOLLOWING = 2;
+ const ACCESS_FOLLOWING = User::ACCESS_FOLLOWING;
- const ACCESS_NOBODY = 3;
+ const ACCESS_NOBODY = User::ACCESS_NOBODY;
#[\Override]
public function __toString(): string
@@ -358,42 +353,14 @@ public function isIgnoredByUser(int $userID)
*/
public function getAvatar()
{
- if ($this->avatar === null) {
- $avatar = null;
- if ($this->disableAvatar === 0) {
- if ($this->canSeeAvatar()) {
- if ($this->avatarPathname !== null) {
- $avatar = new StaticAvatar($this->avatarPathname);
- } else {
- $parameters = ['avatar' => null];
- EventHandler::getInstance()->fireAction($this, 'getAvatar', $parameters);
-
- $avatar = $parameters['avatar'];
- if ($avatar !== null && !($avatar instanceof IUserAvatar)) {
- throw new ImplementationException(
- \get_class($avatar),
- IUserAvatar::class
- );
- }
- }
- }
- }
-
- // use default avatar
- if ($avatar === null) {
- $avatar = new DefaultAvatar($this->username ?: '');
- }
-
- $this->avatar = new AvatarDecorator($avatar);
- }
-
- return $this->avatar;
+ return $this->getDecoratedObject()->getAvatar();
}
/**
* Sets the user's avatar.
*
* @since 6.2
+ * @deprecated 6.3
*/
public function setFileAvatar(File $file): void
{
@@ -478,11 +445,7 @@ public function canSeeCoverPhoto()
*/
public function isOnline()
{
- if ($this->getLastActivityTime() > (\TIME_NOW - \USER_ONLINE_TIMEOUT) && $this->canViewOnlineStatus()) {
- return true;
- }
-
- return false;
+ return $this->getDecoratedObject()->isOnline();
}
/**
@@ -760,41 +723,7 @@ public static function getUserProfilesByUsername(array $usernames)
*/
public function isAccessible(string $name, ?int $userID = null): bool
{
- if ($userID === null) {
- $userID = WCF::getUser()->userID;
- }
- $data = ['result' => true, 'name' => $name, 'userID' => $userID];
-
- switch ($this->{$name}) {
- case self::ACCESS_EVERYONE:
- $data['result'] = true;
- break;
-
- case self::ACCESS_REGISTERED:
- $data['result'] = ($userID !== 0 ? true : false);
- break;
-
- case self::ACCESS_FOLLOWING:
- $result = false;
- if ($userID !== 0) {
- if ($userID === $this->userID) {
- $result = true;
- } elseif ($this->isFollowing($userID)) {
- $result = true;
- }
- }
-
- $data['result'] = $result;
- break;
-
- case self::ACCESS_NOBODY:
- $data['result'] = false;
- break;
- }
-
- EventHandler::getInstance()->fireAction($this, 'isAccessible', $data);
-
- return $data['result'];
+ return $this->getDecoratedObject()->isAccessible($name, $userID);
}
/**
@@ -949,14 +878,7 @@ public function getNeverPermission(string $permission)
*/
public function getUserTitle()
{
- if ($this->userTitle !== '') {
- return $this->userTitle;
- }
- if ($this->getRank() !== null && $this->getRank()->showTitle()) {
- return $this->getRank()->getTitle();
- }
-
- return '';
+ return $this->getDecoratedObject()->getUserTitle();
}
public function getRank(): ?UserRank
@@ -1135,16 +1057,6 @@ public function getFormattedUserOption(string $name)
return $option->optionValue;
}
- /**
- * Returns the localized title of the user option with the given name.
- *
- * @since 6.3
- */
- public function getUserOptionTitle(string $name): string
- {
- return ViewableUserOption::getUserOption($name)->getTitle();
- }
-
/**
* Returns true, if the active user has access to the user option with the given name.
*
diff --git a/wcfsetup/install/files/lib/event/user/UserAvatarLoading.class.php b/wcfsetup/install/files/lib/event/user/UserAvatarLoading.class.php
new file mode 100644
index 00000000000..936052336cf
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/user/UserAvatarLoading.class.php
@@ -0,0 +1,35 @@
+
+ * @since 6.3
+ */
+final class UserAvatarLoading implements IPsr14Event
+{
+ public function __construct(
+ public readonly User $user,
+ private ?IUserAvatar $avatar = null,
+ ) {}
+
+ public function getAvatar(): ?IUserAvatar
+ {
+ return $this->avatar;
+ }
+
+ public function setAvatar(?IUserAvatar $avatar): void
+ {
+ $this->avatar = $avatar;
+ }
+}
diff --git a/wcfsetup/install/files/lib/event/user/UserPrivacySettingChecking.class.php b/wcfsetup/install/files/lib/event/user/UserPrivacySettingChecking.class.php
new file mode 100644
index 00000000000..dcab48b54cf
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/user/UserPrivacySettingChecking.class.php
@@ -0,0 +1,36 @@
+
+ * @since 6.3
+ */
+final class UserPrivacySettingChecking implements IPsr14Event
+{
+ public function __construct(
+ public readonly User $user,
+ public readonly string $name,
+ public readonly int $viewerUserID,
+ private bool $accessible,
+ ) {}
+
+ public function isAccessible(): bool
+ {
+ return $this->accessible;
+ }
+
+ public function setAccessible(bool $accessible): void
+ {
+ $this->accessible = $accessible;
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php
index ed4a04fb612..caac67f2755 100644
--- a/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php
+++ b/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php
@@ -8,10 +8,8 @@
use wcf\data\user\group\UserGroup;
use wcf\data\user\option\UserOption;
use wcf\data\user\User;
-use wcf\data\user\UserProfile;
-use wcf\data\user\UserProfileList;
+use wcf\data\user\UserList;
use wcf\event\gridView\admin\UserGridViewInitialized;
-use wcf\system\cache\runtime\FileRuntimeCache;
use wcf\system\gridView\AbstractGridView;
use wcf\system\gridView\GridViewColumn;
use wcf\system\gridView\GridViewRowLink;
@@ -49,7 +47,7 @@
* @license GNU Lesser General Public License
* @since 6.3
*
- * @extends AbstractGridView
+ * @extends AbstractGridView
*/
final class UserGridView extends AbstractGridView
{
@@ -138,7 +136,7 @@ public function __construct()
$provider = new UserInteractions();
$provider->addInteractions([
new Divider(),
- new EditInteraction(UserEditForm::class, static fn(UserProfile $user) => $user->canEdit()),
+ new EditInteraction(UserEditForm::class, static fn(User $user) => $user->canEdit()),
]);
$this->setInteractionProvider($provider);
$this->setBulkInteractionProvider(new UserBulkInteractions());
@@ -146,7 +144,7 @@ public function __construct()
$this->addRowLink(
new GridViewRowLink(
UserEditForm::class,
- isAvailableCallback: static fn(UserProfile $user) => $user->canEdit()
+ isAvailableCallback: static fn(User $user) => $user->canEdit()
)
);
@@ -179,9 +177,9 @@ public function getRows(): array
}
#[\Override]
- protected function createObjectList(): UserProfileList
+ protected function createObjectList(): UserList
{
- $list = new UserProfileList();
+ $list = new UserList();
// `UserList` only joins the user option values when reading the objects,
// sorting by an option requires them when reading the ids as well.
@@ -204,18 +202,10 @@ protected function getInitializedEvent(): UserGridViewInitialized
private function getAvatarRenderer(): AbstractColumnRenderer
{
return new class extends AbstractColumnRenderer {
- #[\Override]
- public function prepare(mixed $value, DatabaseObject $row): void
- {
- \assert($row instanceof UserProfile);
-
- FileRuntimeCache::getInstance()->cacheObjectID($row->avatarFileID);
- }
-
#[\Override]
public function render(mixed $value, DatabaseObject $row): string
{
- \assert($row instanceof UserProfile);
+ \assert($row instanceof User);
return $row->getAvatar()->getImageTag(24);
}
@@ -234,7 +224,7 @@ private function getUsernameRenderer(): DefaultColumnRenderer
#[\Override]
public function render(mixed $value, DatabaseObject $row): string
{
- \assert($row instanceof UserProfile);
+ \assert($row instanceof User);
$html = '' . StringUtil::encodeHTML($row->username) . ' ';
@@ -272,7 +262,7 @@ public function render(mixed $value, DatabaseObject $row): string
return $html;
}
- private function renderStatusIcon(string $icon, string $languageItem, UserProfile $user): string
+ private function renderStatusIcon(string $icon, string $languageItem, User $user): string
{
return \sprintf(
'%s ',
@@ -289,7 +279,7 @@ private function getLastActivityTimeRenderer(): TimeColumnRenderer
#[\Override]
public function render(mixed $value, DatabaseObject $row): string
{
- \assert($row instanceof UserProfile);
+ \assert($row instanceof User);
if ($row->isOnline()) {
return WCF::getLanguage()->get('wcf.user.online')
diff --git a/wcfsetup/install/files/lib/system/interaction/admin/UserContentRemoveInteraction.class.php b/wcfsetup/install/files/lib/system/interaction/admin/UserContentRemoveInteraction.class.php
index 10f298a3e0e..8889fa4833a 100644
--- a/wcfsetup/install/files/lib/system/interaction/admin/UserContentRemoveInteraction.class.php
+++ b/wcfsetup/install/files/lib/system/interaction/admin/UserContentRemoveInteraction.class.php
@@ -3,7 +3,7 @@
namespace wcf\system\interaction\admin;
use wcf\acp\action\UserContentRemoveAction;
-use wcf\data\user\UserProfile;
+use wcf\data\user\User;
use wcf\system\interaction\FormBuilderDialogInteraction;
use wcf\system\request\LinkHandler;
use wcf\system\user\UserAdministrationPolicy;
@@ -25,7 +25,7 @@ public function __construct()
'remove-content',
LinkHandler::getInstance()->getControllerLink(UserContentRemoveAction::class, ['id' => '%s']),
'wcf.acp.content.removeContent',
- static fn(UserProfile $user) => UserAdministrationPolicy::canDelete($user->getDecoratedObject())
+ static fn(User $user) => UserAdministrationPolicy::canDelete($user)
);
}
diff --git a/wcfsetup/install/files/lib/system/interaction/admin/UserInteractions.class.php b/wcfsetup/install/files/lib/system/interaction/admin/UserInteractions.class.php
index 13e80eb137f..e9de8eaa760 100644
--- a/wcfsetup/install/files/lib/system/interaction/admin/UserInteractions.class.php
+++ b/wcfsetup/install/files/lib/system/interaction/admin/UserInteractions.class.php
@@ -5,7 +5,7 @@
use wcf\acp\action\UserBanAction;
use wcf\acp\action\UserExportGdprAction;
use wcf\acp\form\UserMailForm;
-use wcf\data\user\UserProfile;
+use wcf\data\user\User;
use wcf\event\interaction\admin\UserInteractionCollecting;
use wcf\system\event\EventHandler;
use wcf\system\interaction\AbstractInteractionProvider;
@@ -43,64 +43,62 @@ public function __construct()
'enable',
'core/users/%s/enable',
'wcf.acp.user.enable',
- isAvailableCallback: static fn(UserProfile $user) => $user->pendingActivation()
- && UserAdministrationPolicy::canEnable($user->getDecoratedObject()),
+ isAvailableCallback: static fn(User $user) => $user->pendingActivation()
+ && UserAdministrationPolicy::canEnable($user),
interactionEffect: InteractionEffect::ReloadPage
),
new RpcInteraction(
'disable',
'core/users/%s/disable',
'wcf.acp.user.disable',
- isAvailableCallback: static fn(UserProfile $user) => !$user->pendingActivation()
- && UserAdministrationPolicy::canEnable($user->getDecoratedObject()),
+ isAvailableCallback: static fn(User $user) => !$user->pendingActivation()
+ && UserAdministrationPolicy::canEnable($user),
interactionEffect: InteractionEffect::ReloadPage
),
new RpcInteraction(
'confirm-email',
'core/users/%s/confirm-email',
'wcf.acp.user.action.confirmEmail',
- isAvailableCallback: static fn(UserProfile $user) => !$user->isEmailConfirmed()
- && UserAdministrationPolicy::canEnable($user->getDecoratedObject()),
+ isAvailableCallback: static fn(User $user) => !$user->isEmailConfirmed()
+ && UserAdministrationPolicy::canEnable($user),
interactionEffect: InteractionEffect::ReloadPage
),
new RpcInteraction(
'unconfirm-email',
'core/users/%s/unconfirm-email',
'wcf.acp.user.action.unconfirmEmail',
- isAvailableCallback: static fn(UserProfile $user) => $user->isEmailConfirmed()
- && UserAdministrationPolicy::canEnable($user->getDecoratedObject()),
+ isAvailableCallback: static fn(User $user) => $user->isEmailConfirmed()
+ && UserAdministrationPolicy::canEnable($user),
interactionEffect: InteractionEffect::ReloadPage
),
new RpcInteraction(
'resend-activation-mail',
'core/users/%s/resend-activation-mail',
'wcf.acp.user.action.resendActivationMail',
- isAvailableCallback: static fn(UserProfile $user) => UserAdministrationPolicy::canResendActivationMail(
- $user->getDecoratedObject()
- ),
+ isAvailableCallback: static fn(User $user) => UserAdministrationPolicy::canResendActivationMail($user),
interactionEffect: InteractionEffect::ReloadPage
),
new FormBuilderDialogInteraction(
'ban',
LinkHandler::getInstance()->getControllerLink(UserBanAction::class, ['id' => '%s']),
'wcf.acp.user.ban',
- static fn(UserProfile $user) => $user->banned === 0
- && UserAdministrationPolicy::canBan($user->getDecoratedObject()),
+ static fn(User $user) => $user->banned === 0
+ && UserAdministrationPolicy::canBan($user),
InteractionEffect::ReloadPage
),
new RpcInteraction(
'unban',
'core/users/%s/unban',
'wcf.acp.user.unban',
- isAvailableCallback: static fn(UserProfile $user) => $user->banned === 1
- && UserAdministrationPolicy::canBan($user->getDecoratedObject()),
+ isAvailableCallback: static fn(User $user) => $user->banned === 1
+ && UserAdministrationPolicy::canBan($user),
interactionEffect: InteractionEffect::ReloadPage
),
new LinkInteraction(
'send-mail',
UserMailForm::class,
'wcf.acp.user.action.sendMail',
- static fn(UserProfile $user) => UserAdministrationPolicy::canMail($user->getDecoratedObject())
+ static fn(User $user) => UserAdministrationPolicy::canMail($user)
),
new RpcInteraction(
'send-new-password',
@@ -108,20 +106,18 @@ public function __construct()
'wcf.acp.user.action.sendNewPassword',
InteractionConfirmationType::Custom,
'wcf.acp.user.action.sendNewPassword.confirmMessage',
- static fn(UserProfile $user) => UserAdministrationPolicy::canSendNewPassword(
- $user->getDecoratedObject()
- )
+ static fn(User $user) => UserAdministrationPolicy::canSendNewPassword($user)
),
new LinkInteraction(
'export-gdpr',
UserExportGdprAction::class,
'wcf.acp.user.exportGdpr',
- static fn(UserProfile $user) => UserAdministrationPolicy::canExportGdpr($user->getDecoratedObject())
+ static fn(User $user) => UserAdministrationPolicy::canExportGdpr($user)
),
new Divider(),
new DeleteInteraction(
'core/users/%s',
- static fn(UserProfile $user) => UserAdministrationPolicy::canDelete($user->getDecoratedObject())
+ static fn(User $user) => UserAdministrationPolicy::canDelete($user)
),
new UserContentRemoveInteraction(),
]);
@@ -134,6 +130,6 @@ public function __construct()
#[\Override]
public function getObjectClassName(): string
{
- return UserProfile::class;
+ return User::class;
}
}
diff --git a/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserBulkInteractions.class.php b/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserBulkInteractions.class.php
index 7f173bbe06b..cbc999d83f7 100644
--- a/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserBulkInteractions.class.php
+++ b/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserBulkInteractions.class.php
@@ -8,8 +8,8 @@
use wcf\acp\form\UserEmailAddressExportForm;
use wcf\acp\form\UserMailForm;
use wcf\acp\form\UserMergeForm;
-use wcf\data\user\UserProfile;
-use wcf\data\user\UserProfileList;
+use wcf\data\user\User;
+use wcf\data\user\UserList;
use wcf\event\interaction\bulk\admin\UserBulkInteractionCollecting;
use wcf\system\event\EventHandler;
use wcf\system\interaction\bulk\AbstractBulkInteractionProvider;
@@ -38,62 +38,60 @@ public function __construct()
'enable',
'core/users/%s/enable',
'wcf.acp.user.enable',
- isAvailableCallback: static fn(UserProfile $user) => $user->pendingActivation()
- && UserAdministrationPolicy::canEnable($user->getDecoratedObject())
+ isAvailableCallback: static fn(User $user) => $user->pendingActivation()
+ && UserAdministrationPolicy::canEnable($user)
),
new BulkRpcInteraction(
'confirm-email',
'core/users/%s/confirm-email',
'wcf.acp.user.action.confirmEmail',
- isAvailableCallback: static fn(UserProfile $user) => !$user->isEmailConfirmed()
- && UserAdministrationPolicy::canEnable($user->getDecoratedObject())
+ isAvailableCallback: static fn(User $user) => !$user->isEmailConfirmed()
+ && UserAdministrationPolicy::canEnable($user)
),
new BulkRpcInteraction(
'unconfirm-email',
'core/users/%s/unconfirm-email',
'wcf.acp.user.action.unconfirmEmail',
- isAvailableCallback: static fn(UserProfile $user) => $user->isEmailConfirmed()
- && UserAdministrationPolicy::canEnable($user->getDecoratedObject())
+ isAvailableCallback: static fn(User $user) => $user->isEmailConfirmed()
+ && UserAdministrationPolicy::canEnable($user)
),
new BulkRpcInteraction(
'resend-activation-mail',
'core/users/%s/resend-activation-mail',
'wcf.acp.user.action.resendActivationMail',
- isAvailableCallback: static fn(UserProfile $user) => UserAdministrationPolicy::canResendActivationMail(
- $user->getDecoratedObject()
- )
+ isAvailableCallback: static fn(User $user) => UserAdministrationPolicy::canResendActivationMail($user)
),
new BulkFormBuilderDialogInteraction(
'ban',
UserBanAction::class,
'wcf.acp.user.ban',
- static fn(UserProfile $user) => $user->banned === 0
- && UserAdministrationPolicy::canBan($user->getDecoratedObject())
+ static fn(User $user) => $user->banned === 0
+ && UserAdministrationPolicy::canBan($user)
),
new BulkRpcInteraction(
'unban',
'core/users/%s/unban',
'wcf.acp.user.unban',
- isAvailableCallback: static fn(UserProfile $user) => $user->banned === 1
- && UserAdministrationPolicy::canBan($user->getDecoratedObject())
+ isAvailableCallback: static fn(User $user) => $user->banned === 1
+ && UserAdministrationPolicy::canBan($user)
),
new BulkLinkInteraction(
'assign-to-group',
UserAssignToGroupForm::class,
'wcf.acp.user.action.assignToGroup',
- static fn(UserProfile $user) => UserAdministrationPolicy::canEdit($user->getDecoratedObject())
+ static fn(User $user) => UserAdministrationPolicy::canEdit($user)
),
new BulkLinkInteraction(
'merge',
UserMergeForm::class,
'wcf.acp.user.action.merge',
- static fn(UserProfile $user) => UserAdministrationPolicy::canEdit($user->getDecoratedObject())
+ static fn(User $user) => UserAdministrationPolicy::canEdit($user)
),
new BulkLinkInteraction(
'send-mail',
UserMailForm::class,
'wcf.acp.user.action.sendMail',
- static fn(UserProfile $user) => UserAdministrationPolicy::canMail($user->getDecoratedObject())
+ static fn(User $user) => UserAdministrationPolicy::canMail($user)
),
new BulkLinkInteraction(
'export-mail-address',
@@ -107,9 +105,7 @@ public function __construct()
'wcf.acp.user.action.sendNewPassword',
InteractionConfirmationType::Custom,
'wcf.acp.user.action.sendNewPassword.confirmMessage.bulk',
- static fn(UserProfile $user) => UserAdministrationPolicy::canSendNewPassword(
- $user->getDecoratedObject()
- )
+ static fn(User $user) => UserAdministrationPolicy::canSendNewPassword($user)
),
new BulkLinkInteraction(
'revert-content-changes',
@@ -120,7 +116,7 @@ public function __construct()
),
new BulkDeleteInteraction(
'core/users/%s',
- static fn(UserProfile $user) => UserAdministrationPolicy::canDelete($user->getDecoratedObject())
+ static fn(User $user) => UserAdministrationPolicy::canDelete($user)
),
new UserContentRemoveBulkInteraction(),
]);
@@ -133,6 +129,6 @@ public function __construct()
#[\Override]
public function getObjectListClassName(): string
{
- return UserProfileList::class;
+ return UserList::class;
}
}
diff --git a/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserContentRemoveBulkInteraction.class.php b/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserContentRemoveBulkInteraction.class.php
index f084b3e0ff5..a34577b4b97 100644
--- a/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserContentRemoveBulkInteraction.class.php
+++ b/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserContentRemoveBulkInteraction.class.php
@@ -3,7 +3,7 @@
namespace wcf\system\interaction\bulk\admin;
use wcf\acp\action\UserContentRemoveAction;
-use wcf\data\user\UserProfile;
+use wcf\data\user\User;
use wcf\system\interaction\bulk\BulkFormBuilderDialogInteraction;
use wcf\system\user\UserAdministrationPolicy;
use wcf\util\StringUtil;
@@ -24,7 +24,7 @@ public function __construct()
'remove-content',
UserContentRemoveAction::class,
'wcf.acp.content.removeContent',
- static fn(UserProfile $user) => UserAdministrationPolicy::canDelete($user->getDecoratedObject())
+ static fn(User $user) => UserAdministrationPolicy::canDelete($user)
);
}
From 9fac0960e7e25ee954a78b7fe648d89439e1c777 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Tue, 6 Oct 2026 13:18:26 +0200
Subject: [PATCH 032/115] Move user administration permission checks from
`UserAdministrationPolicy` into `User`
---
.../lib/acp/action/UserBanAction.class.php | 6 +-
.../action/UserContentRemoveAction.class.php | 6 +-
.../files/lib/data/user/User.class.php | 97 ++++++++++++
.../core/users/ConfirmUserEmail.class.php | 6 +-
.../core/users/DeleteUser.class.php | 6 +-
.../core/users/DisableUser.class.php | 6 +-
.../core/users/EnableUser.class.php | 6 +-
.../core/users/ResendActivationMail.class.php | 6 +-
.../core/users/SendNewPassword.class.php | 6 +-
.../controller/core/users/UnbanUser.class.php | 6 +-
.../core/users/UnconfirmUserEmail.class.php | 6 +-
.../UserContentRemoveInteraction.class.php | 3 +-
.../admin/UserInteractions.class.php | 23 ++-
.../bulk/admin/UserBulkInteractions.class.php | 23 ++-
...UserContentRemoveBulkInteraction.class.php | 3 +-
.../user/UserAdministrationPolicy.class.php | 144 ------------------
16 files changed, 161 insertions(+), 192 deletions(-)
delete mode 100644 wcfsetup/install/files/lib/system/user/UserAdministrationPolicy.class.php
diff --git a/wcfsetup/install/files/lib/acp/action/UserBanAction.class.php b/wcfsetup/install/files/lib/acp/action/UserBanAction.class.php
index 7e2f9ed1423..f59f9a09791 100644
--- a/wcfsetup/install/files/lib/acp/action/UserBanAction.class.php
+++ b/wcfsetup/install/files/lib/acp/action/UserBanAction.class.php
@@ -12,12 +12,12 @@
use wcf\data\user\UserList;
use wcf\http\Helper;
use wcf\system\exception\IllegalLinkException;
+use wcf\system\exception\PermissionDeniedException;
use wcf\system\form\builder\field\BooleanFormField;
use wcf\system\form\builder\field\DateFormField;
use wcf\system\form\builder\field\dependency\EmptyFormFieldDependency;
use wcf\system\form\builder\field\MultilineTextFormField;
use wcf\system\form\builder\Psr15DialogForm;
-use wcf\system\user\UserAdministrationPolicy;
use wcf\system\WCF;
/**
@@ -98,7 +98,9 @@ private function getUsers(ServerRequestInterface $request): array
}
foreach ($users as $user) {
- UserAdministrationPolicy::assertCanBan($user);
+ if (!$user->canBan()) {
+ throw new PermissionDeniedException();
+ }
}
return $users;
diff --git a/wcfsetup/install/files/lib/acp/action/UserContentRemoveAction.class.php b/wcfsetup/install/files/lib/acp/action/UserContentRemoveAction.class.php
index 3618818666e..8496d40c543 100644
--- a/wcfsetup/install/files/lib/acp/action/UserContentRemoveAction.class.php
+++ b/wcfsetup/install/files/lib/acp/action/UserContentRemoveAction.class.php
@@ -13,11 +13,11 @@
use wcf\data\user\UserList;
use wcf\http\Helper;
use wcf\system\exception\IllegalLinkException;
+use wcf\system\exception\PermissionDeniedException;
use wcf\system\form\builder\field\MultipleSelectionFormField;
use wcf\system\form\builder\NoticeFormNode;
use wcf\system\form\builder\NoticeFormNodeType;
use wcf\system\form\builder\Psr15DialogForm;
-use wcf\system\user\UserAdministrationPolicy;
use wcf\system\WCF;
/**
@@ -90,7 +90,9 @@ private function getUsers(ServerRequestInterface $request): array
}
foreach ($users as $user) {
- UserAdministrationPolicy::assertCanDelete($user);
+ if (!$user->canDelete()) {
+ throw new PermissionDeniedException();
+ }
}
return $users;
diff --git a/wcfsetup/install/files/lib/data/user/User.class.php b/wcfsetup/install/files/lib/data/user/User.class.php
index e129faf8a07..5d115137b8f 100644
--- a/wcfsetup/install/files/lib/data/user/User.class.php
+++ b/wcfsetup/install/files/lib/data/user/User.class.php
@@ -582,6 +582,103 @@ public function canEdit(): bool
return WCF::getSession()->hasPermission('admin.user.canEditUser') && UserGroup::isAccessibleGroup($this->getGroupIDs());
}
+ /**
+ * Returns true if all groups of this user are accessible to the active user
+ * and, unless `$allowSelf` is set, this user is not the active user.
+ *
+ * @since 6.3
+ */
+ public function isManageable(bool $allowSelf = false): bool
+ {
+ if (!$allowSelf && $this->userID === WCF::getUser()->userID) {
+ return false;
+ }
+
+ return UserGroup::isAccessibleGroup($this->getGroupIDs());
+ }
+
+ /**
+ * Returns true if the active user can enable or disable this user and
+ * confirm or unconfirm the email address of this user.
+ *
+ * @since 6.3
+ */
+ public function canEnable(): bool
+ {
+ return WCF::getSession()->hasPermission('admin.user.canEnableUser')
+ && $this->isManageable();
+ }
+
+ /**
+ * Returns true if the active user can ban or unban this user.
+ *
+ * @since 6.3
+ */
+ public function canBan(): bool
+ {
+ return WCF::getSession()->hasPermission('admin.user.canBanUser')
+ && $this->isManageable();
+ }
+
+ /**
+ * Returns true if the active user can send a link to set a new password to
+ * this user, which is not possible for users that log in via a third party
+ * provider.
+ *
+ * @since 6.3
+ */
+ public function canSendNewPassword(): bool
+ {
+ return WCF::getSession()->hasPermission('admin.user.canEditPassword')
+ && $this->authData === ''
+ && $this->isManageable();
+ }
+
+ /**
+ * Returns true if the active user can resend the activation mail to this user.
+ *
+ * @since 6.3
+ */
+ public function canResendActivationMail(): bool
+ {
+ return WCF::getSession()->hasPermission('admin.user.canEnableUser')
+ && $this->canEmailConfirm()
+ && $this->isManageable();
+ }
+
+ /**
+ * Returns true if the active user can delete this user and its content.
+ *
+ * @since 6.3
+ */
+ public function canDelete(): bool
+ {
+ return WCF::getSession()->hasPermission('admin.user.canDeleteUser')
+ && $this->isManageable();
+ }
+
+ /**
+ * Returns true if the active user can export the GDPR data of this user.
+ *
+ * @since 6.3
+ */
+ public function canExportGdpr(): bool
+ {
+ return WCF::getSession()->hasPermission('admin.user.canExportGdprData')
+ && $this->isManageable(true);
+ }
+
+ /**
+ * Returns true if the active user can send an email to this user.
+ *
+ * @since 6.3
+ */
+ public function canMail(): bool
+ {
+ return WCF::getSession()->hasPermission('admin.user.canMailUser')
+ && $this->userID !== WCF::getUser()->userID;
+ }
+
/**
* Returns true, if this user has access to the ACP.
*/
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/ConfirmUserEmail.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/ConfirmUserEmail.class.php
index e4d35f7752d..d91c3b40e2d 100644
--- a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/ConfirmUserEmail.class.php
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/ConfirmUserEmail.class.php
@@ -9,7 +9,7 @@
use wcf\http\Helper;
use wcf\system\endpoint\IController;
use wcf\system\endpoint\PostRequest;
-use wcf\system\user\UserAdministrationPolicy;
+use wcf\system\exception\PermissionDeniedException;
/**
* Marks the email address of the user with the given ID as confirmed.
@@ -27,7 +27,9 @@ public function __invoke(ServerRequestInterface $request, array $variables): Res
{
$user = Helper::fetchObjectFromRequestParameter($variables['id'], User::class);
- UserAdministrationPolicy::assertCanEnable($user);
+ if (!$user->canEnable()) {
+ throw new PermissionDeniedException();
+ }
if (!$user->isEmailConfirmed()) {
new \wcf\command\user\ConfirmUserEmail($user)();
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/DeleteUser.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/DeleteUser.class.php
index 4ea22c5fbd0..5c621132839 100644
--- a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/DeleteUser.class.php
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/DeleteUser.class.php
@@ -10,7 +10,7 @@
use wcf\http\Helper;
use wcf\system\endpoint\DeleteRequest;
use wcf\system\endpoint\IController;
-use wcf\system\user\UserAdministrationPolicy;
+use wcf\system\exception\PermissionDeniedException;
/**
* Deletes the user with the given ID.
@@ -28,7 +28,9 @@ public function __invoke(ServerRequestInterface $request, array $variables): Res
{
$user = Helper::fetchObjectFromRequestParameter($variables['id'], User::class);
- UserAdministrationPolicy::assertCanDelete($user);
+ if (!$user->canDelete()) {
+ throw new PermissionDeniedException();
+ }
// Plugins clean up their data through the events of `UserAction`.
$action = new UserAction([$user], 'delete');
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/DisableUser.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/DisableUser.class.php
index a5034b18b69..2caefff58ae 100644
--- a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/DisableUser.class.php
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/DisableUser.class.php
@@ -9,7 +9,7 @@
use wcf\http\Helper;
use wcf\system\endpoint\IController;
use wcf\system\endpoint\PostRequest;
-use wcf\system\user\UserAdministrationPolicy;
+use wcf\system\exception\PermissionDeniedException;
/**
* Disables the user with the given ID.
@@ -27,7 +27,9 @@ public function __invoke(ServerRequestInterface $request, array $variables): Res
{
$user = Helper::fetchObjectFromRequestParameter($variables['id'], User::class);
- UserAdministrationPolicy::assertCanEnable($user);
+ if (!$user->canEnable()) {
+ throw new PermissionDeniedException();
+ }
if (!$user->pendingActivation()) {
new \wcf\command\user\DisableUser($user)();
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/EnableUser.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/EnableUser.class.php
index fa61b836d0d..a8dc3c09879 100644
--- a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/EnableUser.class.php
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/EnableUser.class.php
@@ -9,7 +9,7 @@
use wcf\http\Helper;
use wcf\system\endpoint\IController;
use wcf\system\endpoint\PostRequest;
-use wcf\system\user\UserAdministrationPolicy;
+use wcf\system\exception\PermissionDeniedException;
/**
* Enables the user with the given ID.
@@ -27,7 +27,9 @@ public function __invoke(ServerRequestInterface $request, array $variables): Res
{
$user = Helper::fetchObjectFromRequestParameter($variables['id'], User::class);
- UserAdministrationPolicy::assertCanEnable($user);
+ if (!$user->canEnable()) {
+ throw new PermissionDeniedException();
+ }
if ($user->pendingActivation()) {
new \wcf\command\user\EnableUser($user)();
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/ResendActivationMail.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/ResendActivationMail.class.php
index 79476093115..4e9bc67ce4e 100644
--- a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/ResendActivationMail.class.php
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/ResendActivationMail.class.php
@@ -9,7 +9,7 @@
use wcf\http\Helper;
use wcf\system\endpoint\IController;
use wcf\system\endpoint\PostRequest;
-use wcf\system\user\UserAdministrationPolicy;
+use wcf\system\exception\PermissionDeniedException;
/**
* Sends a new activation email to the user with the given ID.
@@ -27,7 +27,9 @@ public function __invoke(ServerRequestInterface $request, array $variables): Res
{
$user = Helper::fetchObjectFromRequestParameter($variables['id'], User::class);
- UserAdministrationPolicy::assertCanResendActivationMail($user);
+ if (!$user->canResendActivationMail()) {
+ throw new PermissionDeniedException();
+ }
new \wcf\command\user\ResendActivationMail($user)();
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/SendNewPassword.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/SendNewPassword.class.php
index bf3cd6bc01a..5c2d4821351 100644
--- a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/SendNewPassword.class.php
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/SendNewPassword.class.php
@@ -9,7 +9,7 @@
use wcf\http\Helper;
use wcf\system\endpoint\IController;
use wcf\system\endpoint\PostRequest;
-use wcf\system\user\UserAdministrationPolicy;
+use wcf\system\exception\PermissionDeniedException;
/**
* Sends a link to set a new password to the user with the given ID.
@@ -27,7 +27,9 @@ public function __invoke(ServerRequestInterface $request, array $variables): Res
{
$user = Helper::fetchObjectFromRequestParameter($variables['id'], User::class);
- UserAdministrationPolicy::assertCanSendNewPassword($user);
+ if (!$user->canSendNewPassword()) {
+ throw new PermissionDeniedException();
+ }
new \wcf\command\user\SendNewPassword($user)();
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/UnbanUser.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/UnbanUser.class.php
index dd966ae446e..c5b2d9f8cd7 100644
--- a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/UnbanUser.class.php
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/UnbanUser.class.php
@@ -9,7 +9,7 @@
use wcf\http\Helper;
use wcf\system\endpoint\IController;
use wcf\system\endpoint\PostRequest;
-use wcf\system\user\UserAdministrationPolicy;
+use wcf\system\exception\PermissionDeniedException;
/**
* Lifts the ban of the user with the given ID.
@@ -27,7 +27,9 @@ public function __invoke(ServerRequestInterface $request, array $variables): Res
{
$user = Helper::fetchObjectFromRequestParameter($variables['id'], User::class);
- UserAdministrationPolicy::assertCanBan($user);
+ if (!$user->canBan()) {
+ throw new PermissionDeniedException();
+ }
if ($user->banned !== 0) {
new \wcf\command\user\UnbanUser($user)();
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/UnconfirmUserEmail.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/UnconfirmUserEmail.class.php
index 165f01aefc9..27aa98c997c 100644
--- a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/UnconfirmUserEmail.class.php
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/UnconfirmUserEmail.class.php
@@ -9,7 +9,7 @@
use wcf\http\Helper;
use wcf\system\endpoint\IController;
use wcf\system\endpoint\PostRequest;
-use wcf\system\user\UserAdministrationPolicy;
+use wcf\system\exception\PermissionDeniedException;
/**
* Marks the email address of the user with the given ID as unconfirmed.
@@ -27,7 +27,9 @@ public function __invoke(ServerRequestInterface $request, array $variables): Res
{
$user = Helper::fetchObjectFromRequestParameter($variables['id'], User::class);
- UserAdministrationPolicy::assertCanEnable($user);
+ if (!$user->canEnable()) {
+ throw new PermissionDeniedException();
+ }
if ($user->isEmailConfirmed()) {
new \wcf\command\user\UnconfirmUserEmail($user)();
diff --git a/wcfsetup/install/files/lib/system/interaction/admin/UserContentRemoveInteraction.class.php b/wcfsetup/install/files/lib/system/interaction/admin/UserContentRemoveInteraction.class.php
index 8889fa4833a..7f2c4c39602 100644
--- a/wcfsetup/install/files/lib/system/interaction/admin/UserContentRemoveInteraction.class.php
+++ b/wcfsetup/install/files/lib/system/interaction/admin/UserContentRemoveInteraction.class.php
@@ -6,7 +6,6 @@
use wcf\data\user\User;
use wcf\system\interaction\FormBuilderDialogInteraction;
use wcf\system\request\LinkHandler;
-use wcf\system\user\UserAdministrationPolicy;
use wcf\util\StringUtil;
/**
@@ -25,7 +24,7 @@ public function __construct()
'remove-content',
LinkHandler::getInstance()->getControllerLink(UserContentRemoveAction::class, ['id' => '%s']),
'wcf.acp.content.removeContent',
- static fn(User $user) => UserAdministrationPolicy::canDelete($user)
+ static fn(User $user) => $user->canDelete()
);
}
diff --git a/wcfsetup/install/files/lib/system/interaction/admin/UserInteractions.class.php b/wcfsetup/install/files/lib/system/interaction/admin/UserInteractions.class.php
index e9de8eaa760..a0f8336ea86 100644
--- a/wcfsetup/install/files/lib/system/interaction/admin/UserInteractions.class.php
+++ b/wcfsetup/install/files/lib/system/interaction/admin/UserInteractions.class.php
@@ -18,7 +18,6 @@
use wcf\system\interaction\LinkInteraction;
use wcf\system\interaction\RpcInteraction;
use wcf\system\request\LinkHandler;
-use wcf\system\user\UserAdministrationPolicy;
/**
* Interaction provider for the user administration.
@@ -44,7 +43,7 @@ public function __construct()
'core/users/%s/enable',
'wcf.acp.user.enable',
isAvailableCallback: static fn(User $user) => $user->pendingActivation()
- && UserAdministrationPolicy::canEnable($user),
+ && $user->canEnable(),
interactionEffect: InteractionEffect::ReloadPage
),
new RpcInteraction(
@@ -52,7 +51,7 @@ public function __construct()
'core/users/%s/disable',
'wcf.acp.user.disable',
isAvailableCallback: static fn(User $user) => !$user->pendingActivation()
- && UserAdministrationPolicy::canEnable($user),
+ && $user->canEnable(),
interactionEffect: InteractionEffect::ReloadPage
),
new RpcInteraction(
@@ -60,7 +59,7 @@ public function __construct()
'core/users/%s/confirm-email',
'wcf.acp.user.action.confirmEmail',
isAvailableCallback: static fn(User $user) => !$user->isEmailConfirmed()
- && UserAdministrationPolicy::canEnable($user),
+ && $user->canEnable(),
interactionEffect: InteractionEffect::ReloadPage
),
new RpcInteraction(
@@ -68,14 +67,14 @@ public function __construct()
'core/users/%s/unconfirm-email',
'wcf.acp.user.action.unconfirmEmail',
isAvailableCallback: static fn(User $user) => $user->isEmailConfirmed()
- && UserAdministrationPolicy::canEnable($user),
+ && $user->canEnable(),
interactionEffect: InteractionEffect::ReloadPage
),
new RpcInteraction(
'resend-activation-mail',
'core/users/%s/resend-activation-mail',
'wcf.acp.user.action.resendActivationMail',
- isAvailableCallback: static fn(User $user) => UserAdministrationPolicy::canResendActivationMail($user),
+ isAvailableCallback: static fn(User $user) => $user->canResendActivationMail(),
interactionEffect: InteractionEffect::ReloadPage
),
new FormBuilderDialogInteraction(
@@ -83,7 +82,7 @@ public function __construct()
LinkHandler::getInstance()->getControllerLink(UserBanAction::class, ['id' => '%s']),
'wcf.acp.user.ban',
static fn(User $user) => $user->banned === 0
- && UserAdministrationPolicy::canBan($user),
+ && $user->canBan(),
InteractionEffect::ReloadPage
),
new RpcInteraction(
@@ -91,14 +90,14 @@ public function __construct()
'core/users/%s/unban',
'wcf.acp.user.unban',
isAvailableCallback: static fn(User $user) => $user->banned === 1
- && UserAdministrationPolicy::canBan($user),
+ && $user->canBan(),
interactionEffect: InteractionEffect::ReloadPage
),
new LinkInteraction(
'send-mail',
UserMailForm::class,
'wcf.acp.user.action.sendMail',
- static fn(User $user) => UserAdministrationPolicy::canMail($user)
+ static fn(User $user) => $user->canMail()
),
new RpcInteraction(
'send-new-password',
@@ -106,18 +105,18 @@ public function __construct()
'wcf.acp.user.action.sendNewPassword',
InteractionConfirmationType::Custom,
'wcf.acp.user.action.sendNewPassword.confirmMessage',
- static fn(User $user) => UserAdministrationPolicy::canSendNewPassword($user)
+ static fn(User $user) => $user->canSendNewPassword()
),
new LinkInteraction(
'export-gdpr',
UserExportGdprAction::class,
'wcf.acp.user.exportGdpr',
- static fn(User $user) => UserAdministrationPolicy::canExportGdpr($user)
+ static fn(User $user) => $user->canExportGdpr()
),
new Divider(),
new DeleteInteraction(
'core/users/%s',
- static fn(User $user) => UserAdministrationPolicy::canDelete($user)
+ static fn(User $user) => $user->canDelete()
),
new UserContentRemoveInteraction(),
]);
diff --git a/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserBulkInteractions.class.php b/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserBulkInteractions.class.php
index cbc999d83f7..59dd08553b9 100644
--- a/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserBulkInteractions.class.php
+++ b/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserBulkInteractions.class.php
@@ -18,7 +18,6 @@
use wcf\system\interaction\bulk\BulkLinkInteraction;
use wcf\system\interaction\bulk\BulkRpcInteraction;
use wcf\system\interaction\InteractionConfirmationType;
-use wcf\system\user\UserAdministrationPolicy;
use wcf\system\WCF;
/**
@@ -39,59 +38,59 @@ public function __construct()
'core/users/%s/enable',
'wcf.acp.user.enable',
isAvailableCallback: static fn(User $user) => $user->pendingActivation()
- && UserAdministrationPolicy::canEnable($user)
+ && $user->canEnable()
),
new BulkRpcInteraction(
'confirm-email',
'core/users/%s/confirm-email',
'wcf.acp.user.action.confirmEmail',
isAvailableCallback: static fn(User $user) => !$user->isEmailConfirmed()
- && UserAdministrationPolicy::canEnable($user)
+ && $user->canEnable()
),
new BulkRpcInteraction(
'unconfirm-email',
'core/users/%s/unconfirm-email',
'wcf.acp.user.action.unconfirmEmail',
isAvailableCallback: static fn(User $user) => $user->isEmailConfirmed()
- && UserAdministrationPolicy::canEnable($user)
+ && $user->canEnable()
),
new BulkRpcInteraction(
'resend-activation-mail',
'core/users/%s/resend-activation-mail',
'wcf.acp.user.action.resendActivationMail',
- isAvailableCallback: static fn(User $user) => UserAdministrationPolicy::canResendActivationMail($user)
+ isAvailableCallback: static fn(User $user) => $user->canResendActivationMail()
),
new BulkFormBuilderDialogInteraction(
'ban',
UserBanAction::class,
'wcf.acp.user.ban',
static fn(User $user) => $user->banned === 0
- && UserAdministrationPolicy::canBan($user)
+ && $user->canBan()
),
new BulkRpcInteraction(
'unban',
'core/users/%s/unban',
'wcf.acp.user.unban',
isAvailableCallback: static fn(User $user) => $user->banned === 1
- && UserAdministrationPolicy::canBan($user)
+ && $user->canBan()
),
new BulkLinkInteraction(
'assign-to-group',
UserAssignToGroupForm::class,
'wcf.acp.user.action.assignToGroup',
- static fn(User $user) => UserAdministrationPolicy::canEdit($user)
+ static fn(User $user) => $user->canEdit()
),
new BulkLinkInteraction(
'merge',
UserMergeForm::class,
'wcf.acp.user.action.merge',
- static fn(User $user) => UserAdministrationPolicy::canEdit($user)
+ static fn(User $user) => $user->canEdit()
),
new BulkLinkInteraction(
'send-mail',
UserMailForm::class,
'wcf.acp.user.action.sendMail',
- static fn(User $user) => UserAdministrationPolicy::canMail($user)
+ static fn(User $user) => $user->canMail()
),
new BulkLinkInteraction(
'export-mail-address',
@@ -105,7 +104,7 @@ public function __construct()
'wcf.acp.user.action.sendNewPassword',
InteractionConfirmationType::Custom,
'wcf.acp.user.action.sendNewPassword.confirmMessage.bulk',
- static fn(User $user) => UserAdministrationPolicy::canSendNewPassword($user)
+ static fn(User $user) => $user->canSendNewPassword()
),
new BulkLinkInteraction(
'revert-content-changes',
@@ -116,7 +115,7 @@ public function __construct()
),
new BulkDeleteInteraction(
'core/users/%s',
- static fn(User $user) => UserAdministrationPolicy::canDelete($user)
+ static fn(User $user) => $user->canDelete()
),
new UserContentRemoveBulkInteraction(),
]);
diff --git a/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserContentRemoveBulkInteraction.class.php b/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserContentRemoveBulkInteraction.class.php
index a34577b4b97..d1899973439 100644
--- a/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserContentRemoveBulkInteraction.class.php
+++ b/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserContentRemoveBulkInteraction.class.php
@@ -5,7 +5,6 @@
use wcf\acp\action\UserContentRemoveAction;
use wcf\data\user\User;
use wcf\system\interaction\bulk\BulkFormBuilderDialogInteraction;
-use wcf\system\user\UserAdministrationPolicy;
use wcf\util\StringUtil;
/**
@@ -24,7 +23,7 @@ public function __construct()
'remove-content',
UserContentRemoveAction::class,
'wcf.acp.content.removeContent',
- static fn(User $user) => UserAdministrationPolicy::canDelete($user)
+ static fn(User $user) => $user->canDelete()
);
}
diff --git a/wcfsetup/install/files/lib/system/user/UserAdministrationPolicy.class.php b/wcfsetup/install/files/lib/system/user/UserAdministrationPolicy.class.php
deleted file mode 100644
index 6ce92a27653..00000000000
--- a/wcfsetup/install/files/lib/system/user/UserAdministrationPolicy.class.php
+++ /dev/null
@@ -1,144 +0,0 @@
-
- * @since 6.3
- */
-final class UserAdministrationPolicy
-{
- /**
- * Returns true if all groups of the user are accessible to the active user
- * and, unless `$allowSelf` is set, the user is not the active user.
- */
- public static function isManageable(User $user, bool $allowSelf = false): bool
- {
- if (!$allowSelf && $user->userID === WCF::getUser()->userID) {
- return false;
- }
-
- return UserGroup::isAccessibleGroup($user->getGroupIDs());
- }
-
- /**
- * Returns true if the user can be enabled or disabled and if the email
- * address of the user can be confirmed or unconfirmed.
- */
- public static function canEnable(User $user): bool
- {
- return WCF::getSession()->hasPermission('admin.user.canEnableUser')
- && self::isManageable($user);
- }
-
- public static function canBan(User $user): bool
- {
- return WCF::getSession()->hasPermission('admin.user.canBanUser')
- && self::isManageable($user);
- }
-
- /**
- * Returns true if a link to set a new password can be sent to the user,
- * which is not possible for users that log in via a third party provider.
- */
- public static function canSendNewPassword(User $user): bool
- {
- return WCF::getSession()->hasPermission('admin.user.canEditPassword')
- && $user->authData === ''
- && self::isManageable($user);
- }
-
- public static function canResendActivationMail(User $user): bool
- {
- return WCF::getSession()->hasPermission('admin.user.canEnableUser')
- && $user->canEmailConfirm()
- && self::isManageable($user);
- }
-
- /**
- * Returns true if the user and its content can be deleted.
- */
- public static function canDelete(User $user): bool
- {
- return WCF::getSession()->hasPermission('admin.user.canDeleteUser')
- && self::isManageable($user);
- }
-
- public static function canExportGdpr(User $user): bool
- {
- return WCF::getSession()->hasPermission('admin.user.canExportGdprData')
- && self::isManageable($user, true);
- }
-
- public static function canMail(User $user): bool
- {
- return WCF::getSession()->hasPermission('admin.user.canMailUser')
- && $user->userID !== WCF::getUser()->userID;
- }
-
- public static function canEdit(User $user): bool
- {
- return $user->canEdit();
- }
-
- /**
- * @throws PermissionDeniedException
- */
- public static function assertCanEnable(User $user): void
- {
- if (!self::canEnable($user)) {
- throw new PermissionDeniedException();
- }
- }
-
- /**
- * @throws PermissionDeniedException
- */
- public static function assertCanBan(User $user): void
- {
- if (!self::canBan($user)) {
- throw new PermissionDeniedException();
- }
- }
-
- /**
- * @throws PermissionDeniedException
- */
- public static function assertCanSendNewPassword(User $user): void
- {
- if (!self::canSendNewPassword($user)) {
- throw new PermissionDeniedException();
- }
- }
-
- /**
- * @throws PermissionDeniedException
- */
- public static function assertCanResendActivationMail(User $user): void
- {
- if (!self::canResendActivationMail($user)) {
- throw new PermissionDeniedException();
- }
- }
-
- /**
- * @throws PermissionDeniedException
- */
- public static function assertCanDelete(User $user): void
- {
- if (!self::canDelete($user)) {
- throw new PermissionDeniedException();
- }
- }
-}
From 74a6789c342d3b045c14b2dd95338e5497b0c4a7 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Tue, 6 Oct 2026 13:49:47 +0200
Subject: [PATCH 033/115] Add `UserOption::getFormattedValue()` to deduplicate
option value formatting
---
.../files/lib/data/user/option/UserOption.class.php | 12 ++++++++++++
.../lib/system/gridView/admin/UserGridView.class.php | 6 +-----
.../view/user/profile/UserProfileAboutView.class.php | 6 +-----
3 files changed, 14 insertions(+), 10 deletions(-)
diff --git a/wcfsetup/install/files/lib/data/user/option/UserOption.class.php b/wcfsetup/install/files/lib/data/user/option/UserOption.class.php
index bb04b9c9ec6..8250f9b12c2 100644
--- a/wcfsetup/install/files/lib/data/user/option/UserOption.class.php
+++ b/wcfsetup/install/files/lib/data/user/option/UserOption.class.php
@@ -373,4 +373,16 @@ public function setRequired(bool $isRequired): void
{
$this->data['required'] = $isRequired ? 1 : 0;
}
+
+ /**
+ * @since 6.3
+ */
+ public function getFormattedValue(string $value): string
+ {
+ return $this->getFormOption()->getFormatter()->format(
+ $value,
+ WCF::getLanguage()->languageID,
+ $this->getConfiguration()
+ );
+ }
}
diff --git a/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php
index caac67f2755..85c0cce292a 100644
--- a/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php
+++ b/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php
@@ -334,11 +334,7 @@ public function render(mixed $value, DatabaseObject $row): string
return '';
}
- return $this->option->getFormOption()->getFormatter()->format(
- $value,
- WCF::getLanguage()->languageID,
- $this->option->getConfiguration()
- );
+ return $this->option->getFormattedValue($value);
}
};
}
diff --git a/wcfsetup/install/files/lib/system/view/user/profile/UserProfileAboutView.class.php b/wcfsetup/install/files/lib/system/view/user/profile/UserProfileAboutView.class.php
index da58a157db3..0f41417cf5c 100644
--- a/wcfsetup/install/files/lib/system/view/user/profile/UserProfileAboutView.class.php
+++ b/wcfsetup/install/files/lib/system/view/user/profile/UserProfileAboutView.class.php
@@ -98,11 +98,7 @@ private function getCategoryOptionValuesOutput(array $options): array
$formattedValue = match ($option->optionType) {
'birthday' => $this->user->getBirthday(),
'aboutMe', 'message' => $this->formatMessage($option, (string)$value),
- default => $option->getFormOption()->getFormatter()->format(
- (string)$value,
- WCF::getLanguage()->languageID,
- $option->getConfiguration()
- ),
+ default => $option->getFormattedValue((string)$value),
};
if ($formattedValue === '') {
From 1a48ea6315d30500619d1e6d357bf88f17bcad5f Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Tue, 6 Oct 2026 14:39:32 +0200
Subject: [PATCH 034/115] Remove user option output classes in favor of
`UserOption::getFormattedValue()`
---
com.woltlab.wcf/fileDelete.xml | 11 +
com.woltlab.wcf/templateDelete.xml | 1 +
.../templates/messageUserOptionOutput.tpl | 11 -
com.woltlab.wcf/userOption.xml | 7 -
phpstan-baseline.neon | 54 ---
.../update_com.woltlab.wcf_6.3_step1.php | 3 +
.../lib/acp/form/UserOptionAddForm.class.php | 323 +++++++-----------
.../lib/data/user/option/UserOption.class.php | 108 +++++-
.../user/option/UserOptionBuilder.class.php | 7 -
.../user/option/ViewableUserOption.class.php | 48 +--
.../gridView/admin/UserGridView.class.php | 4 +-
.../user/BirthdayUserOptionOutput.class.php | 25 --
.../user/DateUserOptionOutput.class.php | 56 ---
.../user/FacebookUserOptionOutput.class.php | 27 --
.../user/FloatUserOptionOutput.class.php | 27 --
.../option/user/IUserOptionOutput.class.php | 1 +
.../user/ImageUserOptionOutput.class.php | 27 --
.../user/LabeledUrlUserOptionOutput.class.php | 42 ---
.../user/MessageUserOptionOutput.class.php | 51 ---
.../NewlineToBreakUserOptionOutput.class.php | 27 --
.../SelectOptionsUserOptionOutput.class.php | 76 -----
.../user/TwitterUserOptionOutput.class.php | 27 --
.../option/user/URLUserOptionOutput.class.php | 43 ---
...rOptionPackageInstallationPlugin.class.php | 16 +-
.../profile/UserProfileAboutView.class.php | 29 +-
wcfsetup/install/lang/de.xml | 8 +-
wcfsetup/install/lang/en.xml | 8 +-
wcfsetup/setup/db/install_com.woltlab.wcf.php | 2 -
28 files changed, 260 insertions(+), 809 deletions(-)
delete mode 100644 com.woltlab.wcf/templates/messageUserOptionOutput.tpl
delete mode 100644 wcfsetup/install/files/lib/system/option/user/BirthdayUserOptionOutput.class.php
delete mode 100644 wcfsetup/install/files/lib/system/option/user/DateUserOptionOutput.class.php
delete mode 100644 wcfsetup/install/files/lib/system/option/user/FacebookUserOptionOutput.class.php
delete mode 100644 wcfsetup/install/files/lib/system/option/user/FloatUserOptionOutput.class.php
delete mode 100644 wcfsetup/install/files/lib/system/option/user/ImageUserOptionOutput.class.php
delete mode 100644 wcfsetup/install/files/lib/system/option/user/LabeledUrlUserOptionOutput.class.php
delete mode 100644 wcfsetup/install/files/lib/system/option/user/MessageUserOptionOutput.class.php
delete mode 100644 wcfsetup/install/files/lib/system/option/user/NewlineToBreakUserOptionOutput.class.php
delete mode 100644 wcfsetup/install/files/lib/system/option/user/SelectOptionsUserOptionOutput.class.php
delete mode 100644 wcfsetup/install/files/lib/system/option/user/TwitterUserOptionOutput.class.php
delete mode 100644 wcfsetup/install/files/lib/system/option/user/URLUserOptionOutput.class.php
diff --git a/com.woltlab.wcf/fileDelete.xml b/com.woltlab.wcf/fileDelete.xml
index 8d48270497e..98a93a5ada2 100644
--- a/com.woltlab.wcf/fileDelete.xml
+++ b/com.woltlab.wcf/fileDelete.xml
@@ -97,6 +97,17 @@
lib/system/upload/AvatarUploadFileValidationStrategy.class.php
lib/system/upload/UserCoverPhotoUploadFileSaveStrategy.class.php
lib/system/upload/UserCoverPhotoUploadFileValidationStrategy.class.php
+ lib/system/option/user/BirthdayUserOptionOutput.class.php
+ lib/system/option/user/DateUserOptionOutput.class.php
+ lib/system/option/user/FacebookUserOptionOutput.class.php
+ lib/system/option/user/FloatUserOptionOutput.class.php
+ lib/system/option/user/ImageUserOptionOutput.class.php
+ lib/system/option/user/LabeledUrlUserOptionOutput.class.php
+ lib/system/option/user/MessageUserOptionOutput.class.php
+ lib/system/option/user/NewlineToBreakUserOptionOutput.class.php
+ lib/system/option/user/SelectOptionsUserOptionOutput.class.php
+ lib/system/option/user/TwitterUserOptionOutput.class.php
+ lib/system/option/user/URLUserOptionOutput.class.php
icon/font-awesome/v6/brands/42-group.svg
icon/font-awesome/v6/brands/500px.svg
diff --git a/com.woltlab.wcf/templateDelete.xml b/com.woltlab.wcf/templateDelete.xml
index 4c1b5aecbc0..74dbcc801a6 100644
--- a/com.woltlab.wcf/templateDelete.xml
+++ b/com.woltlab.wcf/templateDelete.xml
@@ -127,5 +127,6 @@
selectSearchableOptionType
textSearchableOptionType
textareaSearchableOptionType
+ messageUserOptionOutput
diff --git a/com.woltlab.wcf/templates/messageUserOptionOutput.tpl b/com.woltlab.wcf/templates/messageUserOptionOutput.tpl
deleted file mode 100644
index d6da2f16af6..00000000000
--- a/com.woltlab.wcf/templates/messageUserOptionOutput.tpl
+++ /dev/null
@@ -1,11 +0,0 @@
-{hascontent}
-
- {content}
- {unsafe:$value}
- {/content}
-
-
-
-{/hascontent}
diff --git a/com.woltlab.wcf/userOption.xml b/com.woltlab.wcf/userOption.xml
index 61ad8e762a9..225c6a018ee 100644
--- a/com.woltlab.wcf/userOption.xml
+++ b/com.woltlab.wcf/userOption.xml
@@ -52,7 +52,6 @@
profile.aboutMe
aboutMe
- wcf\system\option\user\MessageUserOptionOutput
15
3
com.woltlab.wcf.user.aboutMe
@@ -60,7 +59,6 @@
profile.personal
birthday
- wcf\system\option\user\BirthdayUserOptionOutput
1
15
3
@@ -79,7 +77,6 @@
1:wcf.user.gender.male
2:wcf.user.gender.female
3:wcf.user.gender.other
- wcf\system\option\user\SelectOptionsUserOptionOutput
1
15
3
@@ -108,7 +105,6 @@
profile.personal
textarea
- wcf\system\option\user\NewlineToBreakUserOptionOutput
2
2
@@ -116,7 +112,6 @@
profile.contact
URL
^$|^https?://
- wcf\system\option\user\URLUserOptionOutput
1
15
3
@@ -125,7 +120,6 @@
profile.contact
text
^$|^[A-Za-z\d.]{5,}$
- wcf\system\option\user\FacebookUserOptionOutput
1
15
3
@@ -135,7 +129,6 @@
profile.contact
text
^$|^[A-Za-z0-9_]+$
- wcf\system\option\user\TwitterUserOptionOutput
1
15
3
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
index 3fef83c12c4..e6b60a40b81 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -2167,60 +2167,6 @@ parameters:
count: 2
path: wcfsetup/install/files/lib/system/option/UseroptionsOptionType.class.php
- -
- message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
- identifier: empty.notAllowed
- count: 1
- path: wcfsetup/install/files/lib/system/option/user/DateUserOptionOutput.class.php
-
- -
- message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
- identifier: empty.notAllowed
- count: 1
- path: wcfsetup/install/files/lib/system/option/user/FacebookUserOptionOutput.class.php
-
- -
- message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
- identifier: empty.notAllowed
- count: 1
- path: wcfsetup/install/files/lib/system/option/user/FloatUserOptionOutput.class.php
-
- -
- message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
- identifier: empty.notAllowed
- count: 1
- path: wcfsetup/install/files/lib/system/option/user/ImageUserOptionOutput.class.php
-
- -
- message: '#^Strict comparison using \=\=\= between string and null will always evaluate to false\.$#'
- identifier: identical.alwaysFalse
- count: 1
- path: wcfsetup/install/files/lib/system/option/user/MessageUserOptionOutput.class.php
-
- -
- message: '#^Strict comparison using \=\=\= between string and null will always evaluate to false\.$#'
- identifier: identical.alwaysFalse
- count: 1
- path: wcfsetup/install/files/lib/system/option/user/NewlineToBreakUserOptionOutput.class.php
-
- -
- message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
- identifier: empty.notAllowed
- count: 2
- path: wcfsetup/install/files/lib/system/option/user/SelectOptionsUserOptionOutput.class.php
-
- -
- message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
- identifier: empty.notAllowed
- count: 1
- path: wcfsetup/install/files/lib/system/option/user/TwitterUserOptionOutput.class.php
-
- -
- message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
- identifier: empty.notAllowed
- count: 1
- path: wcfsetup/install/files/lib/system/option/user/URLUserOptionOutput.class.php
-
-
message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
identifier: empty.notAllowed
diff --git a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php
index 1c8344f159a..600df09d99b 100644
--- a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php
+++ b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php
@@ -68,6 +68,9 @@
->columns([
DefaultFalseBooleanDatabaseTableColumn::create('showOnUserCard'),
MediumtextDatabaseTableColumn::create('configuration'),
+ NotNullVarchar255DatabaseTableColumn::create('outputClass')
+ ->defaultValue('')
+ ->drop(),
VarcharDatabaseTableColumn::create('l10nIdentifier')
->length(255),
]),
diff --git a/wcfsetup/install/files/lib/acp/form/UserOptionAddForm.class.php b/wcfsetup/install/files/lib/acp/form/UserOptionAddForm.class.php
index c816d0509cc..0ecd91e0f0d 100644
--- a/wcfsetup/install/files/lib/acp/form/UserOptionAddForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/UserOptionAddForm.class.php
@@ -3,20 +3,18 @@
namespace wcf\acp\form;
use Laminas\Diactoros\Response\HtmlResponse;
-use wcf\command\user\option\CreateUserOption;
-use wcf\command\user\option\UpdateUserOption;
-use wcf\data\DatabaseObjectBuilder;
+use wcf\data\IStorableObject;
use wcf\data\user\option\category\UserOptionCategory;
use wcf\data\user\option\category\UserOptionCategoryList;
use wcf\data\user\option\UserOption;
-use wcf\data\user\option\UserOptionBuilder;
-use wcf\form\AbstractDatabaseObjectBuilderForm;
+use wcf\data\user\option\UserOptionAction;
+use wcf\data\user\option\UserOptionEditor;
+use wcf\form\AbstractFormBuilderForm;
use wcf\http\error\HtmlErrorRenderer;
use wcf\system\form\builder\container\FormContainer;
+use wcf\system\form\builder\data\processor\CustomFormDataProcessor;
use wcf\system\form\builder\field\BooleanFormField;
-use wcf\system\form\builder\field\ClassNameFormField;
use wcf\system\form\builder\field\dependency\ValueFormFieldDependency;
-use wcf\system\form\builder\field\IFormField;
use wcf\system\form\builder\field\IntegerFormField;
use wcf\system\form\builder\field\ItemListFormField;
use wcf\system\form\builder\field\MultilineItemListFormField;
@@ -25,13 +23,10 @@
use wcf\system\form\builder\field\TextFormField;
use wcf\system\form\builder\field\validation\FormFieldValidationError;
use wcf\system\form\builder\field\validation\FormFieldValidator;
-use wcf\system\option\user\DateUserOptionOutput;
-use wcf\system\option\user\IUserOptionOutput;
-use wcf\system\option\user\LabeledUrlUserOptionOutput;
-use wcf\system\option\user\MessageUserOptionOutput;
-use wcf\system\option\user\SelectOptionsUserOptionOutput;
-use wcf\system\option\user\URLUserOptionOutput;
+use wcf\system\form\builder\IFormDocument;
+use wcf\system\language\I18nHandler;
use wcf\system\WCF;
+use wcf\util\StringUtil;
/**
* Shows the user option add form.
@@ -40,9 +35,9 @@
* @copyright 2001-2024 WoltLab GmbH
* @license GNU Lesser General Public License
*
- * @extends AbstractDatabaseObjectBuilderForm
+ * @extends AbstractFormBuilderForm
*/
-class UserOptionAddForm extends AbstractDatabaseObjectBuilderForm
+class UserOptionAddForm extends AbstractFormBuilderForm
{
/**
* @inheritDoc
@@ -95,7 +90,12 @@ class UserOptionAddForm extends AbstractDatabaseObjectBuilderForm
/**
* @inheritDoc
*/
- public string $objectEditLinkController = UserOptionEditForm::class;
+ public $objectActionClass = UserOptionAction::class;
+
+ /**
+ * @inheritDoc
+ */
+ public $objectEditLinkController = UserOptionEditForm::class;
#[\Override]
public function readParameters()
@@ -124,62 +124,26 @@ public function readParameters()
}
#[\Override]
- protected function getDatabaseObjectBuilder(): UserOptionBuilder
- {
- if ($this->formObject !== null) {
- return UserOptionBuilder::forUpdate($this->formObject);
- }
-
- return UserOptionBuilder::forCreate()->setGenericOptionName();
- }
-
- #[\Override]
- protected function getCommand(DatabaseObjectBuilder $builder): callable
+ public function createForm()
{
- if ($this->formObject !== null) {
- return new UpdateUserOption($builder);
- }
+ parent::createForm();
- return new CreateUserOption($builder);
- }
-
- #[\Override]
- protected function createForm(): void
- {
- $formAction = $this->formAction;
$this->form->appendChildren([
FormContainer::create('general')
->appendChildren([
- // The localized title and description are stored in the
- // `wcf1_user_option_l10n` table via the builder, not in
- // columns of `wcf1_user_option`.
TextFormField::create('optionName')
->label('wcf.global.name')
->required()
- ->l10n()
- ->saveValueCallback(static function (UserOptionBuilder $builder, TextFormField $field) {
- $builder->setL10nTitle($field->getL10nValues());
- })
- ->loadValueCallback(static function (UserOption $object, IFormField $field) {
- $field->value($object->getL10nValues('title'));
- }),
+ ->i18n()
+ ->i18nRequired()
+ ->languageItemPattern('wcf.user.option.(option\d+|\w+)'),
MultilineTextFormField::create('optionDescription')
->label('wcf.acp.user.option.description')
- ->l10n()
- ->saveValueCallback(static function (UserOptionBuilder $builder, MultilineTextFormField $field) {
- $builder->setL10nDescription($field->getL10nValues());
- })
- ->loadValueCallback(static function (UserOption $object, IFormField $field) {
- $field->value($object->getL10nValues('description'));
- }),
+ ->i18n()
+ ->i18nRequired()
+ ->languageItemPattern('wcf.user.option.(option\d+|\w+).description'),
BooleanFormField::create('isDisabled')
- ->label('wcf.global.button.disable')
- ->saveValueCallback(static function (UserOptionBuilder $builder, IFormField $field) {
- $builder->setIsDisabled((bool)$field->getSaveValue());
- })
- ->loadValueCallback(static function (UserOption $object, IFormField $field) {
- $field->value($object->isDisabled);
- }),
+ ->label('wcf.global.button.disable'),
SingleSelectionFormField::create('categoryName')
->label('wcf.global.category')
->required()
@@ -190,22 +154,10 @@ protected function createForm(): void
}
return $options;
- })
- ->saveValueCallback(static function (UserOptionBuilder $builder, IFormField $field) {
- $builder->setCategoryName((string)$field->getSaveValue());
- })
- ->loadValueCallback(static function (UserOption $object, IFormField $field) {
- $field->value($object->categoryName);
}),
IntegerFormField::create('showOrder')
->label('wcf.form.field.showOrder')
->value(0)
- ->saveValueCallback(static function (UserOptionBuilder $builder, IFormField $field) {
- $builder->setShowOrder($field->getSaveValue());
- })
- ->loadValueCallback(static function (UserOption $object, IFormField $field) {
- $field->value($object->showOrder);
- }),
]),
FormContainer::create('typeDataContainer')
->label('wcf.acp.user.option.typeData')
@@ -216,43 +168,16 @@ protected function createForm(): void
->required()
->immutable($this->formAction !== 'create')
->options(\array_combine(self::$availableOptionTypes, self::$availableOptionTypes))
- ->value('text')
- ->saveValueCallback(static function (UserOptionBuilder $builder, IFormField $field) {
- $builder->setOptionType((string)$field->getSaveValue());
- })
- ->loadValueCallback(static function (UserOption $object, IFormField $field) {
- $field->value($object->optionType);
- }),
+ ->value('text'),
TextFormField::create('defaultValue')
->label('wcf.acp.user.option.defaultValue')
->description('wcf.acp.user.option.defaultValue.description')
- ->addFieldClass('long')
- ->loadValueCallback(static function (UserOption $object, IFormField $field) {
- $field->value($object->defaultValue ?? '');
- })
- ->saveValueCallback(static function (UserOptionBuilder $builder, IFormField $field) {
- // type-cast the default value
- $defaultValue = $field->getValue();
- $builder->setDefaultValue(
- match ($field->getDocument()->getFormField('optionType')->getValue()) {
- 'boolean', 'integer' => \intval($defaultValue),
- 'float' => \floatval($defaultValue),
- 'date' => \preg_match('/\d{4}-\d{2}-\d{2}/', (string)$defaultValue) ? $defaultValue : '',
- default => $defaultValue,
- }
- );
- }),
+ ->addFieldClass('long'),
MultilineItemListFormField::create('selectOptions')
->label('wcf.acp.user.option.selectOptions')
->description('wcf.acp.user.option.selectOptions.description')
->required()
->saveValueType(ItemListFormField::SAVE_VALUE_TYPE_NSV)
- ->saveValueCallback(static function (UserOptionBuilder $builder, IFormField $field) {
- $builder->setSelectOptions($field->getSaveValue());
- })
- ->loadValueCallback(static function (UserOption $object, IFormField $field) {
- $field->value($object->selectOptions ?? '');
- })
->addDependency(
ValueFormFieldDependency::create('optionType')
->fieldId('optionType')
@@ -286,43 +211,11 @@ protected function createForm(): void
}
})
)
- ->saveValueCallback(static function (UserOptionBuilder $builder, IFormField $field) {
- $builder->setLabeledUrl((string)$field->getSaveValue());
- })
- ->loadValueCallback(static function (UserOption $object, IFormField $field) {
- $field->value($object->labeledUrl ?? '');
- })
->addDependency(
ValueFormFieldDependency::create('optionType')
->fieldId('optionType')
->values(['labeledUrl'])
),
- ClassNameFormField::create('outputClass')
- ->label('wcf.acp.user.option.outputClass')
- ->description('wcf.acp.user.option.outputClass.description')
- ->implementedInterface(IUserOptionOutput::class)
- ->saveValueCallback(static function (UserOptionBuilder $builder, IFormField $field) use ($formAction) {
- // handle auto-assign of the output class on create
- $outputClass = $field->getValue();
- $optionType = $field->getDocument()->getFormField('optionType')->getValue();
- if ($formAction === 'create' && $outputClass === '') {
- if (\in_array($optionType, self::$optionTypesUsingSelectOptions, true)) {
- $outputClass = SelectOptionsUserOptionOutput::class;
- } else {
- $outputClass = match ($optionType) {
- 'date' => DateUserOptionOutput::class,
- 'URL' => URLUserOptionOutput::class,
- 'labeledUrl' => LabeledUrlUserOptionOutput::class,
- 'message' => MessageUserOptionOutput::class,
- default => ''
- };
- }
- }
- $builder->setOutputClass($outputClass);
- })
- ->loadValueCallback(static function (UserOption $object, IFormField $field) {
- $field->value($object->outputClass);
- }),
]),
FormContainer::create('access')
->label('wcf.acp.user.option.access')
@@ -335,13 +228,7 @@ protected function createForm(): void
3 => 'wcf.acp.user.option.editable.3',
6 => 'wcf.acp.user.option.editable.6',
])
- ->value(3)
- ->saveValueCallback(static function (UserOptionBuilder $builder, IFormField $field) {
- $builder->setEditable((int)$field->getSaveValue());
- })
- ->loadValueCallback(static function (UserOption $object, IFormField $field) {
- $field->value($object->editable);
- }),
+ ->value(3),
SingleSelectionFormField::create('visible')
->label('wcf.acp.user.option.visible')
->options([
@@ -352,22 +239,10 @@ protected function createForm(): void
7 => 'wcf.acp.user.option.visible.7',
15 => 'wcf.acp.user.option.visible.15',
])
- ->value(15)
- ->saveValueCallback(static function (UserOptionBuilder $builder, IFormField $field) {
- $builder->setVisible((int)$field->getSaveValue());
- })
- ->loadValueCallback(static function (UserOption $object, IFormField $field) {
- $field->value($object->visible);
- }),
+ ->value(15),
TextFormField::create('validationPattern')
->label('wcf.acp.user.option.validationPattern')
->description('wcf.acp.user.option.validationPattern.description')
- ->saveValueCallback(static function (UserOptionBuilder $builder, IFormField $field) {
- $builder->setValidationPattern((string)$field->getSaveValue());
- })
- ->loadValueCallback(static function (UserOption $object, IFormField $field) {
- $field->value($object->validationPattern ?? '');
- })
->addDependency(
ValueFormFieldDependency::create('validationPatternOptionTypeDependency')
->fieldId('optionType')
@@ -376,73 +251,117 @@ protected function createForm(): void
),
BooleanFormField::create('required')
->label('wcf.acp.user.option.required')
- ->value(false)
- ->saveValueCallback(static function (UserOptionBuilder $builder, IFormField $field) {
- $builder->setRequired((bool)$field->getSaveValue());
- })
- ->loadValueCallback(static function (UserOption $object, IFormField $field) {
- $field->value($object->required);
- }),
+ ->value(false),
BooleanFormField::create('askDuringRegistration')
->label('wcf.acp.user.option.askDuringRegistration')
- ->value(false)
- ->saveValueCallback(static function (UserOptionBuilder $builder, IFormField $field) {
- $builder->setAskDuringRegistration((bool)$field->getSaveValue());
- })
- ->loadValueCallback(static function (UserOption $object, IFormField $field) {
- $field->value($object->askDuringRegistration);
- }),
+ ->value(false),
BooleanFormField::create('searchable')
->label('wcf.acp.user.option.searchable')
- ->value(false)
- ->saveValueCallback(static function (UserOptionBuilder $builder, IFormField $field) {
- $builder->setSearchable((bool)$field->getSaveValue());
- })
- ->loadValueCallback(static function (UserOption $object, IFormField $field) {
- $field->value($object->searchable);
- }),
+ ->value(false),
BooleanFormField::create('showOnUserCard')
->label('wcf.acp.user.option.showOnUserCard')
- ->value(false)
- ->saveValueCallback(static function (UserOptionBuilder $builder, IFormField $field) {
- $builder->setShowOnUserCard((bool)$field->getSaveValue());
- })
- ->loadValueCallback(static function (UserOption $object, IFormField $field) {
- $field->value($object->showOnUserCard);
- }),
- ]),
+ ->value(false),
+ ])
]);
}
#[\Override]
- public function save(): void
+ protected function finalizeForm()
{
- if ($this->formAction === 'create') {
- $this->additionalFields['packageID'] = \PACKAGE_ID;
- }
+ parent::finalizeForm();
+
+ $this->form->getDataHandler()
+ ->addProcessor(
+ new CustomFormDataProcessor(
+ 'optionNameDataProcessor',
+ function (IFormDocument $document, array $parameters) {
+ // These values are unconditionally stored in phrases and
+ // never in actual columns as it is usually the case with
+ // the `I18nHandler`.
+ unset($parameters['data']['optionName']);
+ unset($parameters['data']['optionDescription']);
+
+ return $parameters;
+ },
+ function (IFormDocument $document, array $data, IStorableObject $object) {
+ \assert($object instanceof UserOption);
+ $data['optionName'] = 'wcf.user.option.' . $object->optionName;
+ $data['optionDescription'] = 'wcf.user.option.' . $object->optionName . '.description';
+
+ return $data;
+ }
+ ),
+ )
+ ->addProcessor(
+ new CustomFormDataProcessor(
+ 'additionDataProcessor',
+ function (IFormDocument $document, array $parameters) {
+ $additionalData = $this->formObject?->additionalData ?: [];
+
+ if ($parameters['data']['optionType'] === 'select') {
+ $additionalData['allowEmptyValue'] = true;
+ } elseif ($parameters['data']['optionType'] === 'message') {
+ $additionalData['messageObjectType'] = 'com.woltlab.wcf.user.option.generic';
+ }
+
+ $parameters['data']['additionalData'] = \serialize($additionalData);
+
+ return $parameters;
+ }
+ )
+ )
+ ->addProcessor(
+ new CustomFormDataProcessor(
+ 'defaultValueDataProcessor',
+ function (IFormDocument $document, array $parameters) {
+ $optionType = $parameters['data']['optionType'];
+ $defaultValue = $parameters['data']['defaultValue'];
- $optionType = (string)$this->getFieldValue('optionType');
+ $parameters['data']['defaultValue'] = match ($optionType) {
+ 'boolean', 'integer' => \intval($defaultValue),
+ 'float' => \floatval($defaultValue),
+ 'date' => \preg_match('/\d{4}-\d{2}-\d{2}/', $defaultValue) ? $defaultValue : '',
+ default => $defaultValue,
+ };
- // additionalData
- $additionalData = $this->formObject?->additionalData ?: [];
- if ($optionType === 'select') {
- $additionalData['allowEmptyValue'] = true;
- } elseif ($optionType === 'message') {
- $additionalData['messageObjectType'] = 'com.woltlab.wcf.user.option.generic';
+ return $parameters;
+ }
+ )
+ );
+ }
+
+ #[\Override]
+ public function save()
+ {
+ if ($this->formAction === 'create') {
+ $this->additionalFields['optionName'] = StringUtil::getRandomID();
+ $this->additionalFields['packageID'] = \PACKAGE_ID;
}
- $this->additionalFields['additionalData'] = \serialize($additionalData);
parent::save();
}
- /**
- * Returns the current value of the form field with the given id.
- */
- private function getFieldValue(string $id): mixed
+ #[\Override]
+ public function saved()
{
- $node = $this->form->getNodeById($id);
- \assert($node instanceof IFormField);
+ $userOption = $this->objectAction->getReturnValues()['returnValues'];
+ \assert($userOption instanceof UserOption);
+
+ I18nHandler::getInstance()->save(
+ 'optionName',
+ 'wcf.user.option.option' . $userOption->optionID,
+ 'wcf.user.option'
+ );
+ I18nHandler::getInstance()->save(
+ 'optionDescription',
+ 'wcf.user.option.option' . $userOption->optionID . '.description',
+ 'wcf.user.option'
+ );
+ $editor = new UserOptionEditor($userOption);
+ $editor->update([
+ 'optionName' => 'option' . $userOption->optionID,
+ ]);
- return $node->getValue();
+ parent::saved();
}
}
diff --git a/wcfsetup/install/files/lib/data/user/option/UserOption.class.php b/wcfsetup/install/files/lib/data/user/option/UserOption.class.php
index 8250f9b12c2..07938dec65e 100644
--- a/wcfsetup/install/files/lib/data/user/option/UserOption.class.php
+++ b/wcfsetup/install/files/lib/data/user/option/UserOption.class.php
@@ -7,9 +7,15 @@
use wcf\data\user\User;
use wcf\system\l10n\L10nDefinition;
use wcf\system\l10n\L10nStorage;
+use wcf\system\form\option\formatter\MultipleSelectionFormatter;
+use wcf\system\form\option\formatter\WysiwygFormatter;
use wcf\system\form\option\FormOptionHandler;
use wcf\system\form\option\IFormOption;
+use wcf\system\html\input\HtmlInputProcessor;
+use wcf\system\message\embedded\object\MessageEmbeddedObjectManager;
use wcf\system\WCF;
+use wcf\util\DateUtil;
+use wcf\util\StringUtil;
/**
* Represents a user option.
@@ -23,7 +29,6 @@
* @property-read 0|1 $askDuringRegistration is `1` if the user option will be shown during registration to be filled out, otherwise `0`
* @property-read int $editable setting for who can edit the user option, see `UserOption::EDITABILITY_*` constants
* @property-read int $visible setting for who can see the user option, see `UserOption::VISIBILITY_*` constants
- * @property-read string $outputClass name of the PHP class implementing `wcf\system\option\user\IUserOptionOutput` for outputting the user option in the user profile
* @property-read 0|1 $searchable is `1` if the user option can be searched, otherwise `0`
* @property-read 0|1 $isDisabled is `1` if the user option is disabled and thus neither shown nor editable, otherwise `0`
* @property-read 0|1 $originIsSystem is `1` if the user option was created by the system and not manually by an administrator, otherwise `0`
@@ -375,14 +380,109 @@ public function setRequired(bool $isRequired): void
}
/**
+ * Returns the formatted value of this option for the given user.
+ *
* @since 6.3
*/
- public function getFormattedValue(string $value): string
+ public function getFormattedValue(string $value, User $user): string
{
- return $this->getFormOption()->getFormatter()->format(
+ return match ($this->optionType) {
+ 'birthday' => $this->formatBirthday($value, $user),
+ 'aboutMe', 'message' => $this->formatMessage($value, $user),
+ 'checkboxes', 'multiSelect' => $this->formatMultipleSelection($value),
+ 'labeledUrl' => $this->formatLabeledUrl($value),
+ default => $this->getFormOption()->getFormatter()->format(
+ $value,
+ WCF::getLanguage()->languageID,
+ $this->getConfiguration()
+ ),
+ };
+ }
+
+ /**
+ * Formats the birthday, the year and the age are only shown if the user
+ * allows it or the active user can view private user options.
+ */
+ private function formatBirthday(string $value, User $user): string
+ {
+ $parts = \explode('-', $value);
+ $birthdayYear = (int)$parts[0];
+ $month = (int)($parts[1] ?? 0);
+ $day = (int)($parts[2] ?? 0);
+ if ($month === 0 || $day === 0) {
+ return '';
+ }
+
+ $showYear = $birthdayYear !== 0
+ && ((bool)$user->birthdayShowYear || WCF::getSession()->hasPermission('admin.general.canViewPrivateUserOptions'));
+
+ $dateFormat = WCF::getLanguage()->get(DateUtil::DATE_FORMAT);
+ if (!$showYear) {
+ $dateFormat = \str_replace('Y', '', $dateFormat);
+ }
+
+ $date = new \DateTimeImmutable($value, WCF::getUser()->getTimeZone());
+ $birthday = DateUtil::localizeDate($date->format($dateFormat), $dateFormat, WCF::getLanguage());
+
+ if ($showYear) {
+ $age = DateUtil::getAge($value);
+ if ($age > 0) {
+ $birthday .= ' (' . $age . ')';
+ }
+ }
+
+ return $birthday;
+ }
+
+ /**
+ * Formats the values of the legacy multiple selection option types that
+ * are separated by newlines.
+ */
+ private function formatMultipleSelection(string $value): string
+ {
+ $formatter = new MultipleSelectionFormatter();
+ $configuration = $this->getConfiguration();
+ if (!isset($configuration['selectOptions'])) {
+ return StringUtil::encodeHTML($value);
+ }
+
+ $labels = [];
+ foreach (\explode("\n", StringUtil::unifyNewlines($value)) as $key) {
+ $label = $formatter->format($key, WCF::getLanguage()->languageID, $configuration);
+ if ($label !== '') {
+ $labels[] = $label;
+ }
+ }
+
+ return \implode(', ', $labels);
+ }
+
+ private function formatLabeledUrl(string $value): string
+ {
+ $url = \sprintf($this->labeledUrl, \rawurlencode($value));
+ if (!\preg_match('~^https?://~i', $url)) {
+ return StringUtil::encodeHTML($value);
+ }
+
+ return StringUtil::getAnchorTag($url, $value, true, true);
+ }
+
+ private function formatMessage(string $value, User $user): string
+ {
+ $objectType = $this->messageObjectType ?: 'com.woltlab.wcf.user.aboutMe';
+
+ // Load embedded objects by parsing the original message again because for the user options, there
+ // is no central way to save the embedded objects in the database.
+ $htmlInputProcessor = new HtmlInputProcessor();
+ $htmlInputProcessor->setContext($objectType, $user->userID);
+ $htmlInputProcessor->processIntermediate($value);
+ $htmlInputProcessor->processEmbeddedContent($value, $objectType, $user->userID);
+ MessageEmbeddedObjectManager::getInstance()->registerTemporaryMessage($htmlInputProcessor);
+
+ return (new WysiwygFormatter($objectType, $user->userID))->format(
$value,
WCF::getLanguage()->languageID,
- $this->getConfiguration()
+ []
);
}
}
diff --git a/wcfsetup/install/files/lib/data/user/option/UserOptionBuilder.class.php b/wcfsetup/install/files/lib/data/user/option/UserOptionBuilder.class.php
index c9415154a60..fd7a61db1e3 100644
--- a/wcfsetup/install/files/lib/data/user/option/UserOptionBuilder.class.php
+++ b/wcfsetup/install/files/lib/data/user/option/UserOptionBuilder.class.php
@@ -221,13 +221,6 @@ public function setShowOnUserCard(bool $showOnUserCard): static
return $this;
}
- public function setOutputClass(string $outputClass): static
- {
- $this->properties['outputClass'] = $outputClass;
-
- return $this;
- }
-
public function setPermissions(string $permissions): static
{
$this->properties['permissions'] = $permissions;
diff --git a/wcfsetup/install/files/lib/data/user/option/ViewableUserOption.class.php b/wcfsetup/install/files/lib/data/user/option/ViewableUserOption.class.php
index 1a08d352982..c3b821ea890 100644
--- a/wcfsetup/install/files/lib/data/user/option/ViewableUserOption.class.php
+++ b/wcfsetup/install/files/lib/data/user/option/ViewableUserOption.class.php
@@ -5,11 +5,6 @@
use wcf\data\DatabaseObjectDecorator;
use wcf\data\user\User;
use wcf\system\cache\builder\UserOptionCacheBuilder;
-use wcf\system\exception\ClassNotFoundException;
-use wcf\system\exception\ImplementationException;
-use wcf\system\exception\SystemException;
-use wcf\system\option\user\IUserOptionOutput;
-use wcf\util\StringUtil;
/**
* Represents a viewable user option.
@@ -28,12 +23,6 @@ class ViewableUserOption extends DatabaseObjectDecorator
*/
protected static $baseClass = UserOption::class;
- /**
- * list of output objects
- * @var IUserOptionOutput[]
- */
- public static $outputObjects = [];
-
/**
* cached user options
* @var ViewableUserOption[]
@@ -53,41 +42,14 @@ class ViewableUserOption extends DatabaseObjectDecorator
*/
public function setOptionValue(User $user)
{
- $userOption = 'userOption' . $this->optionID;
- $optionValue = $user->{$userOption};
-
- // use output class
- if ($this->outputClass !== '') {
- $outputObj = $this->getOutputObject();
- $this->optionValue = $outputObj->getOutput($user, $this->getDecoratedObject(), $optionValue);
- } else {
- $this->optionValue = StringUtil::encodeHTML($optionValue);
- }
- }
-
- /**
- * Returns the output object for current user option.
- *
- * @return IUserOptionOutput
- * @throws SystemException
- */
- public function getOutputObject()
- {
- if (!isset(self::$outputObjects[$this->outputClass])) {
- // create instance
- if (!\class_exists($this->outputClass)) {
- throw new ClassNotFoundException($this->outputClass);
- }
-
- // validate interface
- if (!\is_subclass_of($this->outputClass, IUserOptionOutput::class)) {
- throw new ImplementationException($this->outputClass, IUserOptionOutput::class);
- }
+ $optionValue = (string)$user->{$this->getIdentifier()};
+ if ($optionValue === '' || $optionValue === '0000-00-00') {
+ $this->optionValue = '';
- self::$outputObjects[$this->outputClass] = new $this->outputClass();
+ return;
}
- return self::$outputObjects[$this->outputClass];
+ $this->optionValue = $this->getDecoratedObject()->getFormattedValue($optionValue, $user);
}
/**
diff --git a/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php
index 85c0cce292a..96ca6a5dcc6 100644
--- a/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php
+++ b/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php
@@ -334,7 +334,9 @@ public function render(mixed $value, DatabaseObject $row): string
return '';
}
- return $this->option->getFormattedValue($value);
+ \assert($row instanceof User);
+
+ return $this->option->getFormattedValue($value, $row);
}
};
}
diff --git a/wcfsetup/install/files/lib/system/option/user/BirthdayUserOptionOutput.class.php b/wcfsetup/install/files/lib/system/option/user/BirthdayUserOptionOutput.class.php
deleted file mode 100644
index 7ad619ce339..00000000000
--- a/wcfsetup/install/files/lib/system/option/user/BirthdayUserOptionOutput.class.php
+++ /dev/null
@@ -1,25 +0,0 @@
-
- */
-class BirthdayUserOptionOutput extends DateUserOptionOutput
-{
- #[\Override]
- public function getOutput(User $user, UserOption $option, string $value)
- {
- $profile = new UserProfile($user);
-
- return $profile->getBirthday();
- }
-}
diff --git a/wcfsetup/install/files/lib/system/option/user/DateUserOptionOutput.class.php b/wcfsetup/install/files/lib/system/option/user/DateUserOptionOutput.class.php
deleted file mode 100644
index a6b8a854f1e..00000000000
--- a/wcfsetup/install/files/lib/system/option/user/DateUserOptionOutput.class.php
+++ /dev/null
@@ -1,56 +0,0 @@
-
- */
-class DateUserOptionOutput implements IUserOptionOutput
-{
- #[\Override]
- public function getOutput(User $user, UserOption $option, string $value)
- {
- if (empty($value) || $value === '0000-00-00') {
- return '';
- }
-
- $date = self::splitDate($value);
-
- return \IntlDateFormatter::formatObject(
- WCF::getUser()->getLocalDate(\gmmktime(12, 1, 1, $date['month'], $date['day'], $date['year'])),
- [
- \IntlDateFormatter::LONG,
- \IntlDateFormatter::NONE,
- ],
- WCF::getLanguage()->getLocale()
- );
- }
-
- /**
- * Splits the given dashed date into its components.
- *
- * @return int[]
- */
- protected static function splitDate(string $value)
- {
- $year = $month = $day = 0;
- $optionValue = \explode('-', $value);
- $year = \intval($optionValue[0]);
- if (isset($optionValue[1])) {
- $month = \intval($optionValue[1]);
- }
- if (isset($optionValue[2])) {
- $day = \intval($optionValue[2]);
- }
-
- return ['year' => $year, 'month' => $month, 'day' => $day];
- }
-}
diff --git a/wcfsetup/install/files/lib/system/option/user/FacebookUserOptionOutput.class.php b/wcfsetup/install/files/lib/system/option/user/FacebookUserOptionOutput.class.php
deleted file mode 100644
index 95c903c91fb..00000000000
--- a/wcfsetup/install/files/lib/system/option/user/FacebookUserOptionOutput.class.php
+++ /dev/null
@@ -1,27 +0,0 @@
-
- */
-class FacebookUserOptionOutput implements IUserOptionOutput
-{
- #[\Override]
- public function getOutput(User $user, UserOption $option, string $value)
- {
- if (empty($value)) {
- return '';
- }
-
- return StringUtil::getAnchorTag('https://www.facebook.com/' . $value, $value, true, true);
- }
-}
diff --git a/wcfsetup/install/files/lib/system/option/user/FloatUserOptionOutput.class.php b/wcfsetup/install/files/lib/system/option/user/FloatUserOptionOutput.class.php
deleted file mode 100644
index ed9318b55d1..00000000000
--- a/wcfsetup/install/files/lib/system/option/user/FloatUserOptionOutput.class.php
+++ /dev/null
@@ -1,27 +0,0 @@
-
- */
-class FloatUserOptionOutput implements IUserOptionOutput
-{
- #[\Override]
- public function getOutput(User $user, UserOption $option, string $value)
- {
- if (empty($value) || (float)$value === 0.0) {
- $value = 0.00;
- }
-
- return StringUtil::formatDouble($value, 2);
- }
-}
diff --git a/wcfsetup/install/files/lib/system/option/user/IUserOptionOutput.class.php b/wcfsetup/install/files/lib/system/option/user/IUserOptionOutput.class.php
index 7f35890bddc..ae98ed2bee0 100644
--- a/wcfsetup/install/files/lib/system/option/user/IUserOptionOutput.class.php
+++ b/wcfsetup/install/files/lib/system/option/user/IUserOptionOutput.class.php
@@ -11,6 +11,7 @@
* @author Marcel Werk
* @copyright 2001-2019 WoltLab GmbH
* @license GNU Lesser General Public License
+ * @deprecated 6.3
*/
interface IUserOptionOutput
{
diff --git a/wcfsetup/install/files/lib/system/option/user/ImageUserOptionOutput.class.php b/wcfsetup/install/files/lib/system/option/user/ImageUserOptionOutput.class.php
deleted file mode 100644
index 7c59ed9ccd0..00000000000
--- a/wcfsetup/install/files/lib/system/option/user/ImageUserOptionOutput.class.php
+++ /dev/null
@@ -1,27 +0,0 @@
-
- */
-class ImageUserOptionOutput implements IUserOptionOutput
-{
- #[\Override]
- public function getOutput(User $user, UserOption $option, string $value)
- {
- if (empty($value)) {
- return '';
- }
-
- return ' ';
- }
-}
diff --git a/wcfsetup/install/files/lib/system/option/user/LabeledUrlUserOptionOutput.class.php b/wcfsetup/install/files/lib/system/option/user/LabeledUrlUserOptionOutput.class.php
deleted file mode 100644
index fb70ac03bc9..00000000000
--- a/wcfsetup/install/files/lib/system/option/user/LabeledUrlUserOptionOutput.class.php
+++ /dev/null
@@ -1,42 +0,0 @@
-
- * @since 5.4
- */
-class LabeledUrlUserOptionOutput implements IUserOptionOutput
-{
- #[\Override]
- public function getOutput(User $user, UserOption $option, string $value)
- {
- if ($value !== '') {
- $url = self::getURL($option, $value);
-
- if (!\preg_match('~^https?://~i', $url)) {
- return StringUtil::encodeHTML($value);
- }
-
- return StringUtil::getAnchorTag($url, $value, true, true);
- }
-
- return '';
- }
-
- /**
- * Formats the URL.
- */
- private static function getURL(UserOption $option, string $value): string
- {
- return \sprintf($option->labeledUrl, \rawurlencode($value));
- }
-}
diff --git a/wcfsetup/install/files/lib/system/option/user/MessageUserOptionOutput.class.php b/wcfsetup/install/files/lib/system/option/user/MessageUserOptionOutput.class.php
deleted file mode 100644
index baaf66bdef9..00000000000
--- a/wcfsetup/install/files/lib/system/option/user/MessageUserOptionOutput.class.php
+++ /dev/null
@@ -1,51 +0,0 @@
-
- */
-class MessageUserOptionOutput implements IUserOptionOutput
-{
- #[\Override]
- public function getOutput(User $user, UserOption $option, string $value)
- {
- if ($value === null) {
- return '';
- }
-
- $value = StringUtil::trim($value);
-
- if ($value === '') {
- return '';
- }
-
- // Load embedded objects by parsing the original message again because for the user options, there
- // is no central way to save the embedded objects in the database.
- $htmlInputProcessor = new HtmlInputProcessor();
- $htmlInputProcessor->setContext('com.woltlab.wcf.user.aboutMe', $user->userID);
- $htmlInputProcessor->processIntermediate($value);
- $htmlInputProcessor->processEmbeddedContent($value, 'com.woltlab.wcf.user.aboutMe', $user->userID);
- MessageEmbeddedObjectManager::getInstance()->registerTemporaryMessage($htmlInputProcessor);
-
- $htmlOutputProcessor = new HtmlOutputProcessor();
- $htmlOutputProcessor->process($value, 'com.woltlab.wcf.user.aboutMe', $user->userID);
-
- return WCF::getTPL()->render('wcf', 'messageUserOptionOutput', [
- 'option' => $option,
- 'value' => $htmlOutputProcessor->getHtml(),
- ]);
- }
-}
diff --git a/wcfsetup/install/files/lib/system/option/user/NewlineToBreakUserOptionOutput.class.php b/wcfsetup/install/files/lib/system/option/user/NewlineToBreakUserOptionOutput.class.php
deleted file mode 100644
index ee9ebfcf31b..00000000000
--- a/wcfsetup/install/files/lib/system/option/user/NewlineToBreakUserOptionOutput.class.php
+++ /dev/null
@@ -1,27 +0,0 @@
-
- */
-class NewlineToBreakUserOptionOutput implements IUserOptionOutput
-{
- #[\Override]
- public function getOutput(User $user, UserOption $option, string $value)
- {
- if ($value === null) {
- return '';
- }
-
- return \nl2br(StringUtil::encodeHTML($value), false);
- }
-}
diff --git a/wcfsetup/install/files/lib/system/option/user/SelectOptionsUserOptionOutput.class.php b/wcfsetup/install/files/lib/system/option/user/SelectOptionsUserOptionOutput.class.php
deleted file mode 100644
index a447180cad1..00000000000
--- a/wcfsetup/install/files/lib/system/option/user/SelectOptionsUserOptionOutput.class.php
+++ /dev/null
@@ -1,76 +0,0 @@
-
- */
-class SelectOptionsUserOptionOutput implements IUserOptionOutput
-{
- #[\Override]
- public function getOutput(User $user, UserOption $option, string $value)
- {
- $result = self::getResult($option, $value);
-
- if ($result === null) {
- return '';
- }
-
- if (\is_array($result)) {
- $output = '';
- foreach ($result as $resultValue) {
- if (!empty($output)) {
- $output .= " \n";
- }
- $output .= StringUtil::encodeHTML(WCF::getLanguage()->get($resultValue));
- }
-
- return $output;
- } else {
- return StringUtil::encodeHTML(WCF::getLanguage()->get($result));
- }
- }
-
- /**
- * Returns the selected option value(s) for output.
- *
- * @return mixed
- */
- protected static function getResult(UserOption $option, ?string $value)
- {
- if ($value === null) {
- return null;
- }
-
- $options = OptionUtil::parseSelectOptions($option->selectOptions);
-
- // multiselect
- if (\str_contains($value, "\n")) {
- $values = \explode("\n", $value);
- $result = [];
- foreach ($values as $value) {
- if (isset($options[$value])) {
- $result[] = $options[$value];
- }
- }
-
- return $result;
- } else {
- if (!empty($value) && isset($options[$value])) {
- return $options[$value];
- }
-
- return null;
- }
- }
-}
diff --git a/wcfsetup/install/files/lib/system/option/user/TwitterUserOptionOutput.class.php b/wcfsetup/install/files/lib/system/option/user/TwitterUserOptionOutput.class.php
deleted file mode 100644
index 0a1312ad161..00000000000
--- a/wcfsetup/install/files/lib/system/option/user/TwitterUserOptionOutput.class.php
+++ /dev/null
@@ -1,27 +0,0 @@
-
- */
-class TwitterUserOptionOutput implements IUserOptionOutput
-{
- #[\Override]
- public function getOutput(User $user, UserOption $option, string $value)
- {
- if (empty($value)) {
- return '';
- }
-
- return StringUtil::getAnchorTag('https://x.com/' . $value, $value, true, true);
- }
-}
diff --git a/wcfsetup/install/files/lib/system/option/user/URLUserOptionOutput.class.php b/wcfsetup/install/files/lib/system/option/user/URLUserOptionOutput.class.php
deleted file mode 100644
index dc146ce0463..00000000000
--- a/wcfsetup/install/files/lib/system/option/user/URLUserOptionOutput.class.php
+++ /dev/null
@@ -1,43 +0,0 @@
-
- */
-class URLUserOptionOutput implements IUserOptionOutput
-{
- #[\Override]
- public function getOutput(User $user, UserOption $option, string $value)
- {
- if (empty($value) || $value === 'http://') {
- return '';
- }
-
- $value = self::getURL($value);
-
- return StringUtil::getAnchorTag($value, $value, true, true);
- }
-
- /**
- * Formats the URL.
- *
- * @return string
- */
- private static function getURL(string $url)
- {
- if (!\preg_match('~^https?://~i', $url)) {
- $url = 'https://' . $url;
- }
-
- return $url;
- }
-}
diff --git a/wcfsetup/install/files/lib/system/package/plugin/UserOptionPackageInstallationPlugin.class.php b/wcfsetup/install/files/lib/system/package/plugin/UserOptionPackageInstallationPlugin.class.php
index b56957ae46c..154f6e02bdc 100644
--- a/wcfsetup/install/files/lib/system/package/plugin/UserOptionPackageInstallationPlugin.class.php
+++ b/wcfsetup/install/files/lib/system/package/plugin/UserOptionPackageInstallationPlugin.class.php
@@ -15,7 +15,6 @@
use wcf\system\form\builder\container\IFormContainer;
use wcf\system\form\builder\data\processor\CustomFormDataProcessor;
use wcf\system\form\builder\field\BooleanFormField;
-use wcf\system\form\builder\field\ClassNameFormField;
use wcf\system\form\builder\field\dependency\ValueFormFieldDependency;
use wcf\system\form\builder\field\MultilineTextFormField;
use wcf\system\form\builder\field\SingleSelectionFormField;
@@ -23,7 +22,6 @@
use wcf\system\form\builder\field\validation\FormFieldValidationError;
use wcf\system\form\builder\field\validation\FormFieldValidator;
use wcf\system\form\builder\IFormDocument;
-use wcf\system\option\user\IUserOptionOutput;
use wcf\system\option\user\UserOptionHandler;
use wcf\system\Regex;
use wcf\system\WCF;
@@ -68,6 +66,7 @@ class UserOptionPackageInstallationPlugin extends AbstractOptionPackageInstallat
'visible',
'searchable',
'showorder',
+ // no longer supported, ignored for backwards compatibility
'outputclass',
'selectoptions',
'enableoptions',
@@ -114,7 +113,7 @@ protected function saveCategory(array $category)
protected function saveOption(array $option, string $categoryName, int $existingOptionID = 0)
{
// default values
- $optionName = $optionType = $validationPattern = $outputClass = $selectOptions = $enableOptions = $permissions = $options = '';
+ $optionName = $optionType = $validationPattern = $selectOptions = $enableOptions = $permissions = $options = '';
$editable = $visible = $isDisabled = 0;
$defaultValue = $showOrder = null;
@@ -140,9 +139,6 @@ protected function saveOption(array $option, string $categoryName, int $existing
if (isset($option['showorder'])) {
$showOrder = \intval($option['showorder']);
}
- if (isset($option['outputclass'])) {
- $outputClass = $option['outputclass'];
- }
if (isset($option['selectoptions'])) {
$selectOptions = $option['selectoptions'];
}
@@ -190,7 +186,6 @@ protected function saveOption(array $option, string $categoryName, int $existing
'enableOptions' => $enableOptions,
'editable' => $editable,
'visible' => $visible,
- 'outputClass' => $outputClass,
'showOrder' => $showOrder,
'permissions' => $permissions,
'options' => $options,
@@ -358,11 +353,6 @@ protected function addFormFields(IFormDocument $form)
15 => 'wcf.acp.user.option.visible.15',
]),
- ClassNameFormField::create('outputClass')
- ->objectProperty('outputclass')
- ->label('wcf.acp.pip.userOption.options.outputClass')
- ->implementedInterface(IUserOptionOutput::class),
-
BooleanFormField::create('searchable')
->label('wcf.acp.pip.userOption.options.searchable')
->description('wcf.acp.pip.userOption.options.searchable.description'),
@@ -448,7 +438,6 @@ protected function fetchElementData(\DOMElement $element, bool $saveData)
'askDuringRegistration',
'editable',
'visible',
- 'outputClass',
'searchable',
'isDisabled',
'contentPattern',
@@ -528,7 +517,6 @@ protected function prepareXmlElement(\DOMDocument $document, IFormDocument $form
$option,
[
'selectoptions' => '',
- 'outputclass' => '',
'required' => 0,
'askduringregistration' => 0,
'searchable' => 0,
diff --git a/wcfsetup/install/files/lib/system/view/user/profile/UserProfileAboutView.class.php b/wcfsetup/install/files/lib/system/view/user/profile/UserProfileAboutView.class.php
index 0f41417cf5c..fff7ebcaed5 100644
--- a/wcfsetup/install/files/lib/system/view/user/profile/UserProfileAboutView.class.php
+++ b/wcfsetup/install/files/lib/system/view/user/profile/UserProfileAboutView.class.php
@@ -4,11 +4,7 @@
use wcf\data\user\option\UserOption;
use wcf\data\user\UserProfile;
-use wcf\system\form\option\formatter\WysiwygFormatter;
-use wcf\system\html\input\HtmlInputProcessor;
-use wcf\system\message\embedded\object\MessageEmbeddedObjectManager;
use wcf\system\option\user\TUserOptionsByCategory;
-use wcf\system\WCF;
/**
* Represents the view for the "About" tab of the user profile.
@@ -95,11 +91,7 @@ private function getCategoryOptionValuesOutput(array $options): array
continue;
}
- $formattedValue = match ($option->optionType) {
- 'birthday' => $this->user->getBirthday(),
- 'aboutMe', 'message' => $this->formatMessage($option, (string)$value),
- default => $option->getFormattedValue((string)$value),
- };
+ $formattedValue = $option->getFormattedValue((string)$value, $this->user->getDecoratedObject());
if ($formattedValue === '') {
continue;
@@ -113,23 +105,4 @@ private function getCategoryOptionValuesOutput(array $options): array
return $output;
}
-
- private function formatMessage(UserOption $option, string $value): string
- {
- $objectType = $option->messageObjectType ?: 'com.woltlab.wcf.user.aboutMe';
-
- // Load embedded objects by parsing the original message again because for the user options, there
- // is no central way to save the embedded objects in the database.
- $htmlInputProcessor = new HtmlInputProcessor();
- $htmlInputProcessor->setContext($objectType, $this->user->userID);
- $htmlInputProcessor->processIntermediate($value);
- $htmlInputProcessor->processEmbeddedContent($value, $objectType, $this->user->userID);
- MessageEmbeddedObjectManager::getInstance()->registerTemporaryMessage($htmlInputProcessor);
-
- return (new WysiwygFormatter($objectType, $this->user->userID))->format(
- $value,
- WCF::getLanguage()->languageID,
- []
- );
- }
}
diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml
index aa9ab04b86c..6805bd884e6 100644
--- a/wcfsetup/install/lang/de.xml
+++ b/wcfsetup/install/lang/de.xml
@@ -2275,7 +2275,6 @@ Die Datenbestände werden sorgfältig gepflegt, aber es ist nicht ausgeschlossen
-
- wcf\system\html\input\HtmlInputProcessor-Objekt übergeben, dass den Wert der Benutzereinstellung verarbeitet.]]>
@@ -3132,9 +3131,6 @@ Abschnitte dürfen nicht leer sein und nur folgende Zeichen enthalten: [a-z
-
-
-
- Benutzerprofilfeld-Kategorie hinzufügen.]]>
@@ -5541,6 +5537,7 @@ Erlaubte Dateiendungen: {', '|implode:$allowedFileExtensions}]]>
+
@@ -5551,6 +5548,9 @@ Erlaubte Dateiendungen: {', '|implode:$allowedFileExtensions}]]>
+
+
+
diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml
index f38092bcdd2..61ae8b7b89d 100644
--- a/wcfsetup/install/lang/en.xml
+++ b/wcfsetup/install/lang/en.xml
@@ -2486,7 +2486,6 @@ If you have already bought the licenses for the listed apps , th
-
@@ -3061,9 +3060,6 @@ If you have already bought the licenses for the listed apps , th
-
-
-
- add a user field category before creating custom user fields.]]>
@@ -5536,6 +5532,7 @@ Allowed extensions: {', '|implode:$allowedFileExtensions}]]>
+
@@ -5546,6 +5543,9 @@ Allowed extensions: {', '|implode:$allowedFileExtensions}]]>
+
+
+
diff --git a/wcfsetup/setup/db/install_com.woltlab.wcf.php b/wcfsetup/setup/db/install_com.woltlab.wcf.php
index 58b73ba8f86..da56bc73614 100644
--- a/wcfsetup/setup/db/install_com.woltlab.wcf.php
+++ b/wcfsetup/setup/db/install_com.woltlab.wcf.php
@@ -4810,8 +4810,6 @@
TinyintDatabaseTableColumn::create('visible')
->notNull()
->defaultValue(0),
- NotNullVarchar255DatabaseTableColumn::create('outputClass')
- ->defaultValue(''),
DefaultFalseBooleanDatabaseTableColumn::create('searchable'),
NotNullInt10DatabaseTableColumn::create('showOrder')
->defaultValue(0),
From 603c4d9606c6ebfd3d84a91b1b8e98f88b27f3f9 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Tue, 6 Oct 2026 15:19:59 +0200
Subject: [PATCH 035/115] Convert legacy user option types to their form option
equivalents
---
com.woltlab.wcf/userOption.xml | 4 +-
...oltlab.wcf_6.3_userOptionConfiguration.php | 49 ++++++++---
.../lib/acp/form/UserOptionAddForm.class.php | 82 +++++--------------
.../lib/acp/form/UserOptionEditForm.class.php | 34 ++++++--
.../lib/data/user/option/UserOption.class.php | 62 +++++---------
.../files/lib/form/RegisterForm.class.php | 2 +-
.../gridView/admin/UserGridView.class.php | 3 +-
...rOptionPackageInstallationPlugin.class.php | 63 +++-----------
wcfsetup/install/lang/de.xml | 8 +-
wcfsetup/install/lang/en.xml | 8 +-
10 files changed, 131 insertions(+), 184 deletions(-)
diff --git a/com.woltlab.wcf/userOption.xml b/com.woltlab.wcf/userOption.xml
index 225c6a018ee..86b2c5a664d 100644
--- a/com.woltlab.wcf/userOption.xml
+++ b/com.woltlab.wcf/userOption.xml
@@ -51,7 +51,7 @@
profile.aboutMe
- aboutMe
+ wysiwyg
15
3
com.woltlab.wcf.user.aboutMe
@@ -110,7 +110,7 @@
profile.contact
- URL
+ url
^$|^https?://
1
15
diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userOptionConfiguration.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userOptionConfiguration.php
index 70778b01e24..6311e3db5ae 100644
--- a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userOptionConfiguration.php
+++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userOptionConfiguration.php
@@ -1,40 +1,69 @@
*/
+use wcf\data\user\option\UserOption;
use wcf\data\user\option\UserOptionBuilder;
use wcf\system\WCF;
-$sql = "SELECT optionID, selectOptions, required
+$sql = "SELECT optionID, optionType, selectOptions, required
FROM wcf1_user_option";
$statement = WCF::getDB()->prepare($sql);
$statement->execute();
-$configurations = [];
+$options = [];
while ($row = $statement->fetchArray()) {
- $configurations[$row['optionID']] = UserOptionBuilder::buildConfiguration(
- $row['selectOptions'] ?? '',
- (bool)$row['required']
- );
+ $options[$row['optionID']] = [
+ 'configuration' => UserOptionBuilder::buildConfiguration(
+ $row['selectOptions'] ?? '',
+ (bool)$row['required']
+ ),
+ 'optionType' => UserOption::LEGACY_OPTION_TYPES[$row['optionType']] ?? $row['optionType'],
+ 'hasNewlineSeparatedValues' => $row['optionType'] === 'checkboxes' || $row['optionType'] === 'multiSelect',
+ ];
}
$sql = "UPDATE wcf1_user_option
- SET configuration = ?
+ SET configuration = ?,
+ optionType = ?
WHERE optionID = ?";
$statement = WCF::getDB()->prepare($sql);
WCF::getDB()->beginTransaction();
-foreach ($configurations as $optionID => $configuration) {
+foreach ($options as $optionID => $option) {
$statement->execute([
- $configuration,
+ $option['configuration'],
+ $option['optionType'],
$optionID,
]);
}
WCF::getDB()->commitTransaction();
+// The legacy multiple selection option types separate the selected values by
+// newlines, the `checkboxes` form option separates them by commas.
+foreach ($options as $optionID => $option) {
+ if (!$option['hasNewlineSeparatedValues']) {
+ continue;
+ }
+
+ $column = 'userOption' . $optionID;
+ $sql = "UPDATE wcf1_user_option_value
+ SET {$column} = REPLACE(REPLACE({$column}, ?, ?), ?, ?)
+ WHERE {$column} LIKE ?";
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute([
+ "\r\n",
+ "\n",
+ "\n",
+ ',',
+ "%\n%",
+ ]);
+}
+
UserOptionBuilder::resetCache();
diff --git a/wcfsetup/install/files/lib/acp/form/UserOptionAddForm.class.php b/wcfsetup/install/files/lib/acp/form/UserOptionAddForm.class.php
index 0ecd91e0f0d..6b9b7e850cc 100644
--- a/wcfsetup/install/files/lib/acp/form/UserOptionAddForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/UserOptionAddForm.class.php
@@ -9,16 +9,14 @@
use wcf\data\user\option\UserOption;
use wcf\data\user\option\UserOptionAction;
use wcf\data\user\option\UserOptionEditor;
-use wcf\form\AbstractFormBuilderForm;
use wcf\http\error\HtmlErrorRenderer;
use wcf\system\form\builder\container\FormContainer;
use wcf\system\form\builder\data\processor\CustomFormDataProcessor;
use wcf\system\form\builder\field\BooleanFormField;
use wcf\system\form\builder\field\dependency\ValueFormFieldDependency;
use wcf\system\form\builder\field\IntegerFormField;
-use wcf\system\form\builder\field\ItemListFormField;
-use wcf\system\form\builder\field\MultilineItemListFormField;
use wcf\system\form\builder\field\MultilineTextFormField;
+use wcf\system\form\builder\field\SelectFormField;
use wcf\system\form\builder\field\SingleSelectionFormField;
use wcf\system\form\builder\field\TextFormField;
use wcf\system\form\builder\field\validation\FormFieldValidationError;
@@ -35,9 +33,9 @@
* @copyright 2001-2024 WoltLab GmbH
* @license GNU Lesser General Public License
*
- * @extends AbstractFormBuilderForm
+ * @extends AbstractFormOptionAddForm
*/
-class UserOptionAddForm extends AbstractFormBuilderForm
+class UserOptionAddForm extends AbstractFormOptionAddForm
{
/**
* @inheritDoc
@@ -54,39 +52,6 @@ class UserOptionAddForm extends AbstractFormBuilderForm
* @var UserOptionCategory[]
*/
public array $availableCategories = [];
- /**
- * available option types
- * @var string[]
- */
- public static $availableOptionTypes = [
- 'birthday',
- 'boolean',
- 'checkboxes',
- 'date',
- 'integer',
- 'float',
- 'password',
- 'multiSelect',
- 'radioButton',
- 'select',
- 'text',
- 'textarea',
- 'message',
- 'URL',
- 'labeledUrl',
- ];
-
- /**
- * list of option type that require select options
- * @var string[]
- */
- public static $optionTypesUsingSelectOptions = [
- 'checkboxes',
- 'multiSelect',
- 'radioButton',
- 'select',
- ];
-
/**
* @inheritDoc
*/
@@ -162,26 +127,18 @@ public function createForm()
FormContainer::create('typeDataContainer')
->label('wcf.acp.user.option.typeData')
->appendChildren([
- SingleSelectionFormField::create('optionType')
- ->label('wcf.acp.user.option.optionType')
- ->description('wcf.acp.user.option.optionType.description')
- ->required()
- ->immutable($this->formAction !== 'create')
- ->options(\array_combine(self::$availableOptionTypes, self::$availableOptionTypes))
- ->value('text'),
+ $this->getOptionTypeFormField(),
+ ...$this->getSharedConfigurationFormFields(),
TextFormField::create('defaultValue')
->label('wcf.acp.user.option.defaultValue')
->description('wcf.acp.user.option.defaultValue.description')
- ->addFieldClass('long'),
- MultilineItemListFormField::create('selectOptions')
- ->label('wcf.acp.user.option.selectOptions')
- ->description('wcf.acp.user.option.selectOptions.description')
- ->required()
- ->saveValueType(ItemListFormField::SAVE_VALUE_TYPE_NSV)
+ ->addFieldClass('long')
->addDependency(
- ValueFormFieldDependency::create('optionType')
+ // Text options provide their own default value in the configuration.
+ ValueFormFieldDependency::create('defaultValueOptionTypeDependency')
->fieldId('optionType')
- ->values(self::$optionTypesUsingSelectOptions)
+ ->negate()
+ ->values(['text'])
),
TextFormField::create('labeledUrl')
->label('wcf.acp.user.option.labeledUrl')
@@ -246,12 +203,8 @@ public function createForm()
->addDependency(
ValueFormFieldDependency::create('validationPatternOptionTypeDependency')
->fieldId('optionType')
- ->negate()
- ->values(self::$optionTypesUsingSelectOptions)
+ ->values(['text', 'textarea', 'url', 'email', 'password', 'labeledUrl'])
),
- BooleanFormField::create('required')
- ->label('wcf.acp.user.option.required')
- ->value(false),
BooleanFormField::create('askDuringRegistration')
->label('wcf.acp.user.option.askDuringRegistration')
->value(false),
@@ -266,7 +219,14 @@ public function createForm()
}
#[\Override]
- protected function finalizeForm()
+ protected function getOptionTypeFormField(): SelectFormField
+ {
+ return parent::getOptionTypeFormField()
+ ->value('text');
+ }
+
+ #[\Override]
+ public function finalizeForm()
{
parent::finalizeForm();
@@ -300,7 +260,7 @@ function (IFormDocument $document, array $parameters) {
if ($parameters['data']['optionType'] === 'select') {
$additionalData['allowEmptyValue'] = true;
- } elseif ($parameters['data']['optionType'] === 'message') {
+ } elseif ($parameters['data']['optionType'] === 'wysiwyg') {
$additionalData['messageObjectType'] = 'com.woltlab.wcf.user.option.generic';
}
@@ -315,7 +275,7 @@ function (IFormDocument $document, array $parameters) {
'defaultValueDataProcessor',
function (IFormDocument $document, array $parameters) {
$optionType = $parameters['data']['optionType'];
- $defaultValue = $parameters['data']['defaultValue'];
+ $defaultValue = $parameters['data']['defaultValue'] ?? '';
$parameters['data']['defaultValue'] = match ($optionType) {
'boolean', 'integer' => \intval($defaultValue),
diff --git a/wcfsetup/install/files/lib/acp/form/UserOptionEditForm.class.php b/wcfsetup/install/files/lib/acp/form/UserOptionEditForm.class.php
index bf50f74e90b..2645ee785e1 100644
--- a/wcfsetup/install/files/lib/acp/form/UserOptionEditForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/UserOptionEditForm.class.php
@@ -4,10 +4,13 @@
use wcf\acp\page\UserOptionListPage;
use wcf\data\user\option\UserOption;
+use wcf\form\AbstractFormBuilderForm;
use wcf\http\Helper;
-use wcf\system\form\builder\field\SingleSelectionFormField;
+use wcf\system\form\builder\field\SelectFormField;
+use wcf\system\form\option\FormOptionHandler;
use wcf\system\interaction\admin\UserOptionInteractions;
use wcf\system\interaction\StandaloneInteractionContextMenuComponent;
+use wcf\system\language\I18nHandler;
use wcf\system\request\LinkHandler;
use wcf\system\WCF;
@@ -28,7 +31,7 @@ class UserOptionEditForm extends UserOptionAddForm
/**
* @inheritDoc
*/
- public string $formAction = 'edit';
+ public $formAction = 'edit';
#[\Override]
public function readParameters()
@@ -39,21 +42,40 @@ public function readParameters()
}
#[\Override]
- protected function createForm(): void
+ public function createForm()
{
parent::createForm();
- if ($this->formObject->optionName === 'aboutMe') {
+ // Options with a legacy type that has no equivalent form option keep their
+ // type, which must be available to display the immutable field.
+ if (FormOptionHandler::getInstance()->getOption($this->formObject->optionType) === null) {
$optionType = $this->form->getNodeById('optionType');
- \assert($optionType instanceof SingleSelectionFormField);
+ \assert($optionType instanceof SelectFormField);
$optionType->options([
...$optionType->getOptions(),
- 'aboutMe' => 'aboutMe',
+ $this->formObject->optionType => $this->formObject->optionType,
]);
}
}
+ #[\Override]
+ public function saved()
+ {
+ I18nHandler::getInstance()->save(
+ 'optionName',
+ 'wcf.user.option.' . $this->formObject->optionName,
+ 'wcf.user.option'
+ );
+ I18nHandler::getInstance()->save(
+ 'optionDescription',
+ 'wcf.user.option.' . $this->formObject->optionName . '.description',
+ 'wcf.user.option'
+ );
+
+ AbstractFormBuilderForm::saved();
+ }
+
#[\Override]
public function assignVariables()
{
diff --git a/wcfsetup/install/files/lib/data/user/option/UserOption.class.php b/wcfsetup/install/files/lib/data/user/option/UserOption.class.php
index 07938dec65e..675b3f41be5 100644
--- a/wcfsetup/install/files/lib/data/user/option/UserOption.class.php
+++ b/wcfsetup/install/files/lib/data/user/option/UserOption.class.php
@@ -5,13 +5,12 @@
use wcf\data\ITitledObject;
use wcf\data\option\Option;
use wcf\data\user\User;
-use wcf\system\l10n\L10nDefinition;
-use wcf\system\l10n\L10nStorage;
-use wcf\system\form\option\formatter\MultipleSelectionFormatter;
use wcf\system\form\option\formatter\WysiwygFormatter;
use wcf\system\form\option\FormOptionHandler;
use wcf\system\form\option\IFormOption;
use wcf\system\html\input\HtmlInputProcessor;
+use wcf\system\l10n\L10nDefinition;
+use wcf\system\l10n\L10nStorage;
use wcf\system\message\embedded\object\MessageEmbeddedObjectManager;
use wcf\system\WCF;
use wcf\util\DateUtil;
@@ -114,24 +113,27 @@ class UserOption extends Option implements ITitledObject
const EDITABILITY_OWNER_DURING_REGISTRATION_AND_ADMINISTRATOR = 6;
/**
- * Maps the legacy option types to the ids of the form options with a compatible value format.
- * Option types without a compatible form option fall back to the text form option.
+ * Maps the legacy option types without an equivalent form option to the ids of the form
+ * options with a compatible value format. Other legacy option types fall back to the text
+ * form option.
*
* @var array
*/
private const FORM_OPTION_TYPES = [
- 'aboutMe' => 'wysiwyg',
'birthday' => 'date',
- 'boolean' => 'boolean',
- 'date' => 'date',
- 'float' => 'float',
- 'integer' => 'integer',
+ ];
+
+ /**
+ * Maps the legacy option types that are converted to form options.
+ *
+ * @var array
+ * @since 6.3
+ */
+ public const LEGACY_OPTION_TYPES = [
+ 'aboutMe' => 'wysiwyg',
+ 'checkboxes' => 'checkboxes',
'message' => 'wysiwyg',
- 'radioButton' => 'radioButton',
- 'select' => 'select',
- 'text' => 'text',
- 'textarea' => 'textarea',
- 'timezone' => 'timezone',
+ 'multiSelect' => 'checkboxes',
'URL' => 'url',
];
@@ -236,8 +238,8 @@ public function getIdentifier(): string
*/
public function getFormOption(): IFormOption
{
- $formOption = null;
- if (isset(self::FORM_OPTION_TYPES[$this->optionType])) {
+ $formOption = FormOptionHandler::getInstance()->getOption($this->optionType);
+ if ($formOption === null && isset(self::FORM_OPTION_TYPES[$this->optionType])) {
$formOption = FormOptionHandler::getInstance()->getOption(self::FORM_OPTION_TYPES[$this->optionType]);
}
@@ -388,8 +390,7 @@ public function getFormattedValue(string $value, User $user): string
{
return match ($this->optionType) {
'birthday' => $this->formatBirthday($value, $user),
- 'aboutMe', 'message' => $this->formatMessage($value, $user),
- 'checkboxes', 'multiSelect' => $this->formatMultipleSelection($value),
+ 'wysiwyg' => $this->formatMessage($value, $user),
'labeledUrl' => $this->formatLabeledUrl($value),
default => $this->getFormOption()->getFormatter()->format(
$value,
@@ -434,29 +435,6 @@ private function formatBirthday(string $value, User $user): string
return $birthday;
}
- /**
- * Formats the values of the legacy multiple selection option types that
- * are separated by newlines.
- */
- private function formatMultipleSelection(string $value): string
- {
- $formatter = new MultipleSelectionFormatter();
- $configuration = $this->getConfiguration();
- if (!isset($configuration['selectOptions'])) {
- return StringUtil::encodeHTML($value);
- }
-
- $labels = [];
- foreach (\explode("\n", StringUtil::unifyNewlines($value)) as $key) {
- $label = $formatter->format($key, WCF::getLanguage()->languageID, $configuration);
- if ($label !== '') {
- $labels[] = $label;
- }
- }
-
- return \implode(', ', $labels);
- }
-
private function formatLabeledUrl(string $value): string
{
$url = \sprintf($this->labeledUrl, \rawurlencode($value));
diff --git a/wcfsetup/install/files/lib/form/RegisterForm.class.php b/wcfsetup/install/files/lib/form/RegisterForm.class.php
index e6f019f9fdc..8dad5f4fb44 100644
--- a/wcfsetup/install/files/lib/form/RegisterForm.class.php
+++ b/wcfsetup/install/files/lib/form/RegisterForm.class.php
@@ -135,7 +135,7 @@ private function isAskedDuringRegistration(UserOption $option): bool
}
return $option->askDuringRegistration !== 0
- || $option->required !== 0
+ || (bool)($option->getConfiguration()['required'] ?? false)
|| ($option->editable & UserOption::EDITABILITY_OWNER_DURING_REGISTRATION) !== 0
|| $this->isBirthdayWithMinimumAge($option);
}
diff --git a/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php
index 96ca6a5dcc6..990200daf18 100644
--- a/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php
+++ b/wcfsetup/install/files/lib/system/gridView/admin/UserGridView.class.php
@@ -301,8 +301,7 @@ public function render(mixed $value, DatabaseObject $row): string
private function getUserOptionColumns(): array
{
$options = $this->getUserOptionsByCategory('profile', static function (UserOption $option) {
- return $option->optionType !== 'aboutMe'
- && $option->optionType !== 'message'
+ return $option->optionType !== 'wysiwyg'
&& $option->validateOptions()
&& $option->validatePermissions();
});
diff --git a/wcfsetup/install/files/lib/system/package/plugin/UserOptionPackageInstallationPlugin.class.php b/wcfsetup/install/files/lib/system/package/plugin/UserOptionPackageInstallationPlugin.class.php
index 154f6e02bdc..5d9e61c2489 100644
--- a/wcfsetup/install/files/lib/system/package/plugin/UserOptionPackageInstallationPlugin.class.php
+++ b/wcfsetup/install/files/lib/system/package/plugin/UserOptionPackageInstallationPlugin.class.php
@@ -8,7 +8,6 @@
use wcf\data\user\option\category\UserOptionCategory;
use wcf\data\user\option\category\UserOptionCategoryEditor;
use wcf\data\user\option\UserOption;
-use wcf\data\user\option\UserOptionBuilder;
use wcf\data\user\option\UserOptionEditor;
use wcf\system\devtools\pip\IGuiPackageInstallationPlugin;
use wcf\system\exception\SystemException;
@@ -122,7 +121,8 @@ protected function saveOption(array $option, string $categoryName, int $existing
$optionName = $option['name'];
}
if (isset($option['optiontype'])) {
- $optionType = $option['optiontype'];
+ // Legacy option types with an equivalent form option are converted.
+ $optionType = UserOption::LEGACY_OPTION_TYPES[$option['optiontype']] ?? $option['optiontype'];
}
if (isset($option['defaultvalue'])) {
$defaultValue = $option['defaultvalue'];
@@ -203,62 +203,21 @@ protected function saveOption(array $option, string $categoryName, int $existing
$data['searchable'] = \intval($option['searchable']);
}
- // the localized title/description are shipped as `wcf.user.option.*`
- // language variables and synchronized into the l10n storage at the end
- // of the installation, see `SyncL10nLanguageItems`
- $l10nIdentifier = 'wcf.user.option.' . $optionName;
-
// update option
if (!empty($result['optionID']) && $this->installation->getAction() === 'update') {
$userOption = new UserOption(null, $result);
- $builder = UserOptionBuilder::forUpdate($userOption)
- ->setL10nIdentifier($l10nIdentifier);
- $this->applyOptionData($builder, $data, $additionalData);
- $builder->update();
+ $userOptionEditor = new UserOptionEditor($userOption);
+ $userOptionEditor->update($data);
} // insert new option
else {
- $builder = UserOptionBuilder::forCreate()
- ->setOptionName($optionName)
- ->setL10nIdentifier($l10nIdentifier)
- ->setIsDisabled((bool)$isDisabled)
- ->setPackageID($this->installation->getPackageID());
- $this->applyOptionData($builder, $data, $additionalData);
- $builder->create();
- }
- }
+ // append option name
+ $data['optionName'] = $optionName;
+ // append disabled state
+ $data['isDisabled'] = $isDisabled;
- /**
- * Applies the shared option data to the given builder.
- *
- * @param array $data
- * @param array $additionalData
- */
- private function applyOptionData(UserOptionBuilder $builder, array $data, array $additionalData): void
- {
- $builder
- ->setCategoryName($data['categoryName'])
- ->setOptionType($data['optionType'])
- ->setDefaultValue($data['defaultValue'])
- ->setValidationPattern($data['validationPattern'])
- ->setSelectOptions($data['selectOptions'])
- ->setEnableOptions($data['enableOptions'])
- ->setEditable((int)$data['editable'])
- ->setVisible((int)$data['visible'])
- ->setOutputClass($data['outputClass'])
- ->setShowOrder((int)$data['showOrder'])
- ->setPermissions($data['permissions'])
- ->setOptions($data['options'])
- ->setAdditionalData($additionalData)
- ->setOriginIsSystem((bool)$data['originIsSystem']);
-
- if (isset($data['required'])) {
- $builder->setRequired((bool)$data['required']);
- }
- if (isset($data['askDuringRegistration'])) {
- $builder->setAskDuringRegistration((bool)$data['askDuringRegistration']);
- }
- if (isset($data['searchable'])) {
- $builder->setSearchable((bool)$data['searchable']);
+ // create option
+ $data['packageID'] = $this->installation->getPackageID();
+ UserOptionEditor::create($data);
}
}
diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml
index 6805bd884e6..7c3508dd71a 100644
--- a/wcfsetup/install/lang/de.xml
+++ b/wcfsetup/install/lang/de.xml
@@ -3110,13 +3110,9 @@ Abschnitte dürfen nicht leer sein und nur folgende Zeichen enthalten: [a-z
- %s fehlt in der URL.]]>
- http:// oder
https:// beginnen.]]>
-
-
-
-
Hinweis: Es werden nur die Einstellungen überschrieben, die mit dem Absenden des Formulars geändert werden. Unveränderte Voreinstellungen werden nicht berücksichtigt.]]>
@@ -5548,9 +5544,13 @@ Erlaubte Dateiendungen: {', '|implode:$allowedFileExtensions}]]>
+
+
+
+
diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml
index 61ae8b7b89d..9524820be5e 100644
--- a/wcfsetup/install/lang/en.xml
+++ b/wcfsetup/install/lang/en.xml
@@ -3039,13 +3039,9 @@ If you have already bought the licenses for the listed apps , th
- %s.]]>
- http:// or
https:// .]]>
-
-
-
-
Notice: Only those settings that have been changed will be considered and cause the user’s choices to be overridden. Unchanged options will be ignored.]]>
@@ -5543,9 +5539,13 @@ Allowed extensions: {', '|implode:$allowedFileExtensions}]]>
+
+
+
+
From 3a632b5d9f33c9e1143cecd1072c9a2ea2ea20c4 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Tue, 6 Oct 2026 15:26:53 +0200
Subject: [PATCH 036/115] Remove legacy ban fields from user edit form in favor
of the ban interaction
---
.../install/files/acp/templates/userAdd.tpl | 65 -------------------
.../files/lib/acp/form/UserEditForm.class.php | 48 --------------
wcfsetup/install/lang/de.xml | 4 +-
wcfsetup/install/lang/en.xml | 4 +-
4 files changed, 4 insertions(+), 117 deletions(-)
diff --git a/wcfsetup/install/files/acp/templates/userAdd.tpl b/wcfsetup/install/files/acp/templates/userAdd.tpl
index 0ba81abc7f3..9bb68c43ec0 100644
--- a/wcfsetup/install/files/acp/templates/userAdd.tpl
+++ b/wcfsetup/install/files/acp/templates/userAdd.tpl
@@ -189,71 +189,6 @@
{/if}
{/if}
- {if $action == 'edit' && $__wcf->session->getPermission('admin.user.canBanUser') && $__wcf->user->userID != $userID}
-
-
-
- {/if}
-
{event name='sections'}
diff --git a/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php b/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php
index ecceef952f3..e78ba75af13 100755
--- a/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php
@@ -60,24 +60,6 @@ class UserEditForm extends UserAddForm
*/
public $user;
- /**
- * ban status
- * @var int
- */
- public $banned = 0;
-
- /**
- * ban reason
- * @var string
- */
- public $banReason = '';
-
- /**
- * date when the ban expires
- * @var int
- */
- public $banExpires = 0;
-
/**
* user avatar object
*/
@@ -207,23 +189,6 @@ public function readFormParameters()
$this->email = $this->user->email;
}
- if (!empty($_POST['banned'])) {
- $this->banned = 1;
- }
- if (isset($_POST['banReason'])) {
- $this->banReason = StringUtil::trim($_POST['banReason']);
- }
- if ($this->banned !== 0 && !isset($_POST['banNeverExpires'])) {
- if (isset($_POST['banExpires'])) {
- $banExpires = \DateTimeImmutable::createFromFormat('!Y-m-d', $_POST['banExpires'], new \DateTimeZone(\TIMEZONE));
- if ($banExpires === false) {
- $this->banExpires = 0;
- } else {
- $this->banExpires = $banExpires->getTimestamp();
- }
- }
- }
-
if (isset($_POST['styleID'])) {
$this->styleID = \intval($_POST['styleID']);
}
@@ -330,9 +295,6 @@ protected function readDefaultValues()
$this->email = $this->user->email;
$this->groupIDs = $this->user->getGroupIDs(true);
$this->languageID = $this->user->languageID;
- $this->banned = $this->user->banned;
- $this->banReason = $this->user->banReason;
- $this->banExpires = $this->user->banExpires;
$this->userTitle = $this->user->userTitle;
$this->styleID = $this->user->styleID;
@@ -370,14 +332,11 @@ public function assignVariables()
'action' => 'edit',
'url' => '',
'user' => $this->user,
- 'banned' => $this->banned,
- 'banReason' => $this->banReason,
'avatarType' => $this->avatarType,
'disableAvatar' => $this->disableAvatar,
'disableAvatarReason' => $this->disableAvatarReason,
'disableAvatarExpires' => $this->disableAvatarExpires,
'userAvatar' => $this->userAvatar,
- 'banExpires' => $this->banExpires,
'userCoverPhoto' => $this->userCoverPhoto,
'disableCoverPhoto' => $this->disableCoverPhoto,
'disableCoverPhotoReason' => $this->disableCoverPhotoReason,
@@ -437,13 +396,6 @@ public function save()
$data['data']['oldUsername'] = $this->user->username;
}
- // handle ban
- if (WCF::getSession()->hasPermission('admin.user.canBanUser')) {
- $data['data']['banned'] = $this->banned;
- $data['data']['banReason'] = $this->banReason;
- $data['data']['banExpires'] = $this->banExpires;
- }
-
// handle disabled signature
if (WCF::getSession()->hasPermission('admin.user.canDisableSignature')) {
$data['data']['disableSignature'] = $this->disableSignature;
diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml
index 7c3508dd71a..9b68b1052b4 100644
--- a/wcfsetup/install/lang/de.xml
+++ b/wcfsetup/install/lang/de.xml
@@ -3048,8 +3048,6 @@ Abschnitte dürfen nicht leer sein und nur folgende Zeichen enthalten: [a-z
- 1}Die{else}Der{/if} Benutzer wurde{if $users|count > 1}n{/if} erfolgreich {if $groupIDs|count > 1}den ausgewählten Benutzergruppen{else}der ausgewählten Benutzergruppe{/if} zugeordnet.]]>
-
-
@@ -5814,5 +5812,7 @@ Erlaubte Dateiendungen: {', '|implode:$allowedFileExtensions}]]>
+
+
diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml
index 9524820be5e..67dddf5cef2 100644
--- a/wcfsetup/install/lang/en.xml
+++ b/wcfsetup/install/lang/en.xml
@@ -2977,8 +2977,6 @@ If you have already bought the licenses for the listed apps , th
- 1}s have{else} has{/if} successfully been added to the selected user group{if $groupIDs|count > 1}s{/if}.]]>
-
-
@@ -5816,5 +5814,7 @@ Allowed extensions: {', '|implode:$allowedFileExtensions}]]>
+
+
From afb350d7095df608ab20c043a92c53316f79b0d5 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Tue, 6 Oct 2026 16:05:28 +0200
Subject: [PATCH 037/115] Move ban and enable/disable profile interactions to
generic dialog and RPC interactions
---
com.woltlab.wcf/templates/user.tpl | 11 ---
.../templates/userProfileHeader.tpl | 6 --
ts/WoltLabSuite/Core/Ui/User/Editor.ts | 41 +--------
.../js/WoltLabSuite/Core/Ui/User/Editor.js | 37 +-------
.../{acp => }/action/UserBanAction.class.php | 2 +-
.../admin/UserInteractions.class.php | 2 +-
.../bulk/admin/UserBulkInteractions.class.php | 2 +-
.../user/UserManagementInteractions.class.php | 86 +++++++++----------
wcfsetup/install/lang/de.xml | 10 +--
wcfsetup/install/lang/en.xml | 10 +--
10 files changed, 58 insertions(+), 149 deletions(-)
rename wcfsetup/install/files/lib/{acp => }/action/UserBanAction.class.php (99%)
diff --git a/com.woltlab.wcf/templates/user.tpl b/com.woltlab.wcf/templates/user.tpl
index d37fa4490ee..62319e18283 100644
--- a/com.woltlab.wcf/templates/user.tpl
+++ b/com.woltlab.wcf/templates/user.tpl
@@ -5,15 +5,6 @@
- {/hascontent}
- {/if}
-
- {icon name='list'} {lang}wcf.acp.menu.link.user.list{/lang}
-
- {event name='contentHeaderNavigation'}
-
-
+ {hascontent}
+
+ {/hascontent}
+{if $action === 'edit'}
+ {unsafe:$userProfileHeaderView}
+{/if}
+
{if $userID|isset && $__wcf->user->userID == $userID}
{lang}wcf.acp.user.edit.warning.selfEdit{/lang}
{/if}
diff --git a/wcfsetup/install/files/acp/templates/userProfileHeader.tpl b/wcfsetup/install/files/acp/templates/userProfileHeader.tpl
new file mode 100644
index 00000000000..9dfbf9e8351
--- /dev/null
+++ b/wcfsetup/install/files/acp/templates/userProfileHeader.tpl
@@ -0,0 +1,139 @@
+
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/User/Avatar.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/User/Avatar.js
index 7c044ec97eb..5c0f3b9dfc5 100644
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/User/Avatar.js
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/User/Avatar.js
@@ -38,6 +38,11 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Helper/PromiseMutex",
link = file.link;
}
avatarForm.querySelector("img.userAvatarImage").src = link;
+ // ACP user profile header
+ const headerAvatar = document.querySelector(".userProfileHeader__avatar img.userAvatarImage");
+ if (headerAvatar) {
+ headerAvatar.src = link;
+ }
document
.getElementById("wcf\\action\\UserAvatarAction_avatarFileIDContainer")
?.closest("woltlab-core-dialog")
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/User/CoverPhoto.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/User/CoverPhoto.js
index ae274ae5e97..e17ff61349c 100644
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/User/CoverPhoto.js
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/User/CoverPhoto.js
@@ -23,51 +23,55 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Helper/PromiseMutex",
});
dialog.show(json.title);
}
- function getCoverPhotoElement() {
- return (document.querySelector(".userProfileHeader__coverPhotoImage") ??
- document.getElementById("coverPhotoPreview"));
+ function getDefaultCoverPhoto() {
+ return document.querySelector("[data-default-cover-photo]")?.dataset.defaultCoverPhoto ?? "";
+ }
+ function updateAcpCoverPhotoPreview(fileId, fileUrl) {
+ const coverPhotoPreview = document.getElementById("coverPhotoPreview");
+ const coverPhotoNotice = document.getElementById("coverPhotoNotice");
+ if (fileUrl) {
+ const coverPhotoStyle = `url("${fileUrl}")`;
+ if (coverPhotoPreview) {
+ coverPhotoPreview.style.backgroundImage = coverPhotoStyle;
+ coverPhotoPreview.dataset.objectId = fileId?.toString() || "";
+ }
+ else if (coverPhotoNotice) {
+ coverPhotoNotice.parentElement.appendChild(Util_1.default.createFragmentFromHtml(`
`));
+ coverPhotoNotice.remove();
+ }
+ }
+ else if (coverPhotoPreview) {
+ coverPhotoPreview.parentElement.appendChild(Util_1.default.createFragmentFromHtml(`${(0, Language_1.getPhrase)("wcf.user.coverPhoto.noImage")} `));
+ coverPhotoPreview.remove();
+ }
}
function setup() {
- (0, Selector_1.wheneverFirstSeen)("[data-edit-cover-photo]", (button) => {
- const defaultCoverPhoto = button.dataset.defaultCoverPhoto;
- (0, FileProcessor_1.registerCallback)("wcf\\action\\UserCoverPhotoAction_coverPhotoFileID", (fileId) => {
- const coverPhotoElement = getCoverPhotoElement();
- if (coverPhotoElement && parseInt(coverPhotoElement.dataset.objectId) === fileId) {
- // nothing changed
- return;
- }
- const file = document.querySelector(`#wcf\\\\action\\\\UserCoverPhotoAction_coverPhotoFileIDContainer woltlab-core-file[file-id="${fileId}"]`);
- const coverPhotoNotice = document.getElementById("coverPhotoNotice");
- const coverPhotoUrl = file?.link ?? defaultCoverPhoto ?? "";
- const coverPhotoStyle = `url("${coverPhotoUrl}")`;
- if (coverPhotoElement instanceof HTMLImageElement && coverPhotoUrl) {
- coverPhotoElement.src = coverPhotoUrl;
- coverPhotoElement.dataset.objectId = fileId?.toString() || "";
- document
- .getElementById("wcf\\action\\UserCoverPhotoAction_coverPhotoFileIDContainer")
- ?.closest("woltlab-core-dialog")
- ?.close();
- }
- else {
- // ACP cover photo management
- if (!coverPhotoElement && coverPhotoUrl) {
- coverPhotoNotice.parentElement.appendChild(Util_1.default.createFragmentFromHtml(`
`));
- coverPhotoNotice.remove();
- }
- else if (coverPhotoElement && !coverPhotoUrl) {
- coverPhotoElement.parentElement.appendChild(Util_1.default.createFragmentFromHtml(`${(0, Language_1.getPhrase)("wcf.user.coverPhoto.noImage")} `));
- coverPhotoElement.remove();
- }
- else if (coverPhotoElement && coverPhotoUrl) {
- coverPhotoElement.style.backgroundImage = coverPhotoStyle;
- coverPhotoElement.dataset.objectId = fileId?.toString() || "";
- }
- }
- (0, Snackbar_1.showDefaultSuccessSnackbar)();
- (0, Handler_1.fire)("com.woltlab.wcf.user", "coverPhoto", {
- url: coverPhotoUrl,
- });
+ (0, FileProcessor_1.registerCallback)("wcf\\action\\UserCoverPhotoAction_coverPhotoFileID", (fileId) => {
+ const headerImage = document.querySelector(".userProfileHeader__coverPhotoImage");
+ const coverPhotoElement = headerImage ?? document.getElementById("coverPhotoPreview");
+ if (coverPhotoElement && parseInt(coverPhotoElement.dataset.objectId) === fileId) {
+ // nothing changed
+ return;
+ }
+ const file = document.querySelector(`#wcf\\\\action\\\\UserCoverPhotoAction_coverPhotoFileIDContainer woltlab-core-file[file-id="${fileId}"]`);
+ const fileUrl = file?.link ?? "";
+ const coverPhotoUrl = fileUrl || getDefaultCoverPhoto();
+ if (headerImage && coverPhotoUrl) {
+ headerImage.src = coverPhotoUrl;
+ headerImage.dataset.objectId = fileId?.toString() || "";
+ document
+ .getElementById("wcf\\action\\UserCoverPhotoAction_coverPhotoFileIDContainer")
+ ?.closest("woltlab-core-dialog")
+ ?.close();
+ }
+ // ACP cover photo management
+ updateAcpCoverPhotoPreview(fileId, fileUrl);
+ (0, Snackbar_1.showDefaultSuccessSnackbar)();
+ (0, Handler_1.fire)("com.woltlab.wcf.user", "coverPhoto", {
+ url: coverPhotoUrl,
});
+ });
+ (0, Selector_1.wheneverFirstSeen)("[data-edit-cover-photo]", (button) => {
button.addEventListener("click", (0, PromiseMutex_1.promiseMutex)(() => editCoverPhoto(button)));
});
}
diff --git a/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php b/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php
index e78ba75af13..c2cc24a4add 100755
--- a/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php
@@ -2,7 +2,6 @@
namespace wcf\acp\form;
-use wcf\acp\page\UserListPage;
use wcf\data\file\File;
use wcf\data\style\Style;
use wcf\data\user\cover\photo\IUserCoverPhoto;
@@ -18,14 +17,12 @@
use wcf\system\exception\PermissionDeniedException;
use wcf\system\exception\UserInputException;
use wcf\system\html\upcast\HtmlUpcastProcessor;
-use wcf\system\interaction\admin\UserInteractions;
-use wcf\system\interaction\StandaloneInteractionContextMenuComponent;
use wcf\system\message\embedded\object\MessageEmbeddedObjectManager;
use wcf\system\moderation\queue\ModerationQueueManager;
-use wcf\system\request\LinkHandler;
use wcf\system\style\StyleHandler;
use wcf\command\user\SetColorScheme;
use wcf\system\user\multifactor\Setup;
+use wcf\system\view\user\profile\AdminUserProfileHeaderView;
use wcf\system\WCF;
use wcf\util\StringUtil;
@@ -263,9 +260,8 @@ public function readData()
// get the user cover photo object
if ($this->user->coverPhotoFileID !== null) {
// If the editing user lacks the permissions to view the cover photo, the system
- // will try to load the default cover photo. However, the default cover photo depends
- // on the style, eventually triggering a change to the template group which will
- // fail in the admin panel.
+ // falls back to the default cover photo of the default style, which must not be
+ // shown as the user's own cover photo.
if ($userProfile->canSeeCoverPhoto()) {
$this->userCoverPhoto = UserProfileRuntimeCache::getInstance()
->getObject($this->userID)
@@ -323,10 +319,8 @@ public function assignVariables()
$signatureProcessor->process($this->signature ?: '', 'com.woltlab.wcf.user.signature', $this->user->userID);
WCF::getTPL()->assign([
- 'interactionContextMenu' => StandaloneInteractionContextMenuComponent::forContentHeaderButton(
- new UserInteractions(),
- $this->user->getDecoratedObject(),
- LinkHandler::getInstance()->getControllerLink(UserListPage::class)
+ 'userProfileHeaderView' => new AdminUserProfileHeaderView(
+ UserProfileRuntimeCache::getInstance()->getObject($this->userID)
),
'userID' => $this->user->userID,
'action' => 'edit',
diff --git a/wcfsetup/install/files/lib/event/user/profile/UserProfileStatItemCollecting.class.php b/wcfsetup/install/files/lib/event/user/profile/UserProfileStatItemCollecting.class.php
index 58c4856aa24..8674a331a05 100644
--- a/wcfsetup/install/files/lib/event/user/profile/UserProfileStatItemCollecting.class.php
+++ b/wcfsetup/install/files/lib/event/user/profile/UserProfileStatItemCollecting.class.php
@@ -29,7 +29,7 @@ public function __construct(public readonly UserProfile $user)
$this->register(UserProfileStatItem::forLink(
WCF::getLanguage()->get('wcf.user.reactionsReceived'),
StringUtil::formatNumeric($this->user->likesReceived),
- '#likes'
+ $this->user->getLink() . '#likes'
));
}
diff --git a/wcfsetup/install/files/lib/system/view/user/profile/AdminUserProfileHeaderView.class.php b/wcfsetup/install/files/lib/system/view/user/profile/AdminUserProfileHeaderView.class.php
new file mode 100644
index 00000000000..8e1268a285f
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/view/user/profile/AdminUserProfileHeaderView.class.php
@@ -0,0 +1,157 @@
+
+ * @since 6.3
+ */
+final class AdminUserProfileHeaderView implements \Stringable
+{
+ /**
+ * @var UserProfileStatItem[]
+ */
+ private array $statItems = [];
+
+ /**
+ * @var UserProfileHeaderViewSearchContentLink[]
+ */
+ private array $searchContentLinks = [];
+
+ private StandaloneInteractionContextMenuComponent $interactionContextMenu;
+
+ public function __construct(
+ public readonly UserProfile $user,
+ ) {
+ $this->initStatItems();
+ $this->initSearchContentLinks();
+ $this->initInteractionContextMenu();
+ }
+
+ #[\Override]
+ public function __toString(): string
+ {
+ return WCF::getTPL()->render('wcf', 'userProfileHeader', [
+ 'view' => $this,
+ ]);
+ }
+
+ /**
+ * @return UserProfileStatItem[]
+ */
+ public function getStatItems(): array
+ {
+ return $this->statItems;
+ }
+
+ public function hasSearchContentLinks(): bool
+ {
+ return $this->searchContentLinks !== [];
+ }
+
+ /**
+ * @return UserProfileHeaderViewSearchContentLink[]
+ */
+ public function getSearchContentLinks(): array
+ {
+ return $this->searchContentLinks;
+ }
+
+ public function getInteractionContextMenu(): StandaloneInteractionContextMenuComponent
+ {
+ return $this->interactionContextMenu;
+ }
+
+ /**
+ * Returns the avatar of the user. Unlike the frontend, a disabled avatar
+ * is still shown to the administrator.
+ */
+ public function getAvatarImageTag(): string
+ {
+ if ($this->user->disableAvatar !== 0) {
+ if ($this->user->avatarPathname !== null) {
+ return (new AvatarDecorator(new StaticAvatar($this->user->avatarPathname)))->getImageTag(128);
+ }
+
+ if ($this->user->avatarFileID !== null) {
+ $file = FileRuntimeCache::getInstance()->getObject($this->user->avatarFileID);
+ if ($file !== null) {
+ return (new AvatarDecorator($file))->getImageTag(128);
+ }
+ }
+ }
+
+ return $this->user->getAvatar()->getImageTag(128);
+ }
+
+ /**
+ * Returns the cover photo of the user. Unlike the frontend, a disabled
+ * cover photo is still shown to the administrator.
+ */
+ public function getCoverPhoto(): IUserCoverPhoto
+ {
+ return $this->user->getCoverPhoto(true);
+ }
+
+ public function getDefaultCoverPhotoUrl(): string
+ {
+ return StyleHandler::getInstance()->getStyle()->getCoverPhotoUrl();
+ }
+
+ public function canEditAvatar(): bool
+ {
+ return $this->user->canEditAvatar();
+ }
+
+ public function canEditCoverPhoto(): bool
+ {
+ return $this->user->canEditCoverPhoto();
+ }
+
+ private function initStatItems(): void
+ {
+ $event = new UserProfileStatItemCollecting($this->user);
+ EventHandler::getInstance()->fire($event);
+ $this->statItems = $event->getItems();
+ }
+
+ private function initSearchContentLinks(): void
+ {
+ $event = new UserProfileHeaderSearchContentLinkCollecting($this->user);
+ EventHandler::getInstance()->fire($event);
+ $this->searchContentLinks = $event->getLinks();
+ }
+
+ private function initInteractionContextMenu(): void
+ {
+ $this->interactionContextMenu = new StandaloneInteractionContextMenuComponent(
+ new UserInteractions(),
+ $this->user->getDecoratedObject(),
+ LinkHandler::getInstance()->getControllerLink(UserListPage::class),
+ configuration: new InteractionContextMenuComponentConfiguration(
+ cssClassName: 'userProfileHeader__button',
+ buttonCssClassName: 'button small'
+ )
+ );
+ }
+}
From 38e02021f24479c4f18ac9cce0d66483cea0f3a5 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Tue, 6 Oct 2026 20:03:43 +0200
Subject: [PATCH 042/115] Remove legacy avatar/cover photo tabs from ACP user
edit form
---
phpstan-baseline.neon | 2 +-
ts/WoltLabSuite/Core/Component/User/Avatar.ts | 6 +-
.../Core/Component/User/CoverPhoto.ts | 40 +---
wcfsetup/install/files/acp/style/layout.scss | 15 --
.../install/files/acp/templates/userAdd.tpl | 201 ------------------
.../Core/Component/User/Avatar.js | 5 +-
.../Core/Component/User/CoverPhoto.js | 30 +--
.../files/lib/acp/form/UserEditForm.class.php | 144 -------------
wcfsetup/install/lang/de.xml | 2 +-
wcfsetup/install/lang/en.xml | 2 +-
10 files changed, 10 insertions(+), 437 deletions(-)
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
index e6b60a40b81..7140ec41eb5 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -178,7 +178,7 @@ parameters:
-
message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
identifier: empty.notAllowed
- count: 5
+ count: 2
path: wcfsetup/install/files/lib/acp/form/UserEditForm.class.php
-
diff --git a/ts/WoltLabSuite/Core/Component/User/Avatar.ts b/ts/WoltLabSuite/Core/Component/User/Avatar.ts
index 070ce748b23..d4200d2273c 100644
--- a/ts/WoltLabSuite/Core/Component/User/Avatar.ts
+++ b/ts/WoltLabSuite/Core/Component/User/Avatar.ts
@@ -49,8 +49,7 @@ export function setup(): void {
},
);
- const avatarForm = document.getElementById("avatarForm");
- if (avatarForm) {
+ if (document.body.classList.contains("wcfAcp")) {
registerCallback("wcf\\action\\UserAvatarAction_avatarFileID", (fileId: number | undefined) => {
let link = defaultAvatar;
if (fileId !== undefined) {
@@ -61,9 +60,6 @@ export function setup(): void {
link = file.link!;
}
- avatarForm.querySelector("img.userAvatarImage")!.src = link;
-
- // ACP user profile header
const headerAvatar = document.querySelector(".userProfileHeader__avatar img.userAvatarImage");
if (headerAvatar) {
headerAvatar.src = link;
diff --git a/ts/WoltLabSuite/Core/Component/User/CoverPhoto.ts b/ts/WoltLabSuite/Core/Component/User/CoverPhoto.ts
index b9dfdf2b124..cb0b0bc4a99 100644
--- a/ts/WoltLabSuite/Core/Component/User/CoverPhoto.ts
+++ b/ts/WoltLabSuite/Core/Component/User/CoverPhoto.ts
@@ -14,9 +14,6 @@ import { dialogFactory } from "WoltLabSuite/Core/Component/Dialog";
import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend";
import * as FormBuilderManager from "WoltLabSuite/Core/Form/Builder/Manager";
import { fire as fireEvent } from "WoltLabSuite/Core/Event/Handler";
-import { getPhrase } from "WoltLabSuite/Core/Language";
-import DomUtil from "WoltLabSuite/Core/Dom/Util";
-import { escapeHTML } from "WoltLabSuite/Core/StringUtil";
import { registerCallback } from "WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor";
import WoltlabCoreFile from "WoltLabSuite/Core/Component/File/woltlab-core-file";
import { showDefaultSuccessSnackbar } from "../Snackbar";
@@ -44,40 +41,11 @@ function getDefaultCoverPhoto(): string {
return document.querySelector("[data-default-cover-photo]")?.dataset.defaultCoverPhoto ?? "";
}
-function updateAcpCoverPhotoPreview(fileId: number | undefined, fileUrl: string): void {
- const coverPhotoPreview = document.getElementById("coverPhotoPreview");
- const coverPhotoNotice = document.getElementById("coverPhotoNotice");
-
- if (fileUrl) {
- const coverPhotoStyle = `url("${fileUrl}")`;
-
- if (coverPhotoPreview) {
- coverPhotoPreview.style.backgroundImage = coverPhotoStyle;
- coverPhotoPreview.dataset.objectId = fileId?.toString() || "";
- } else if (coverPhotoNotice) {
- coverPhotoNotice.parentElement!.appendChild(
- DomUtil.createFragmentFromHtml(
- `
`,
- ),
- );
- coverPhotoNotice.remove();
- }
- } else if (coverPhotoPreview) {
- coverPhotoPreview.parentElement!.appendChild(
- DomUtil.createFragmentFromHtml(
- `${getPhrase("wcf.user.coverPhoto.noImage")} `,
- ),
- );
- coverPhotoPreview.remove();
- }
-}
-
export function setup(): void {
registerCallback("wcf\\action\\UserCoverPhotoAction_coverPhotoFileID", (fileId: number | undefined) => {
const headerImage = document.querySelector(".userProfileHeader__coverPhotoImage");
- const coverPhotoElement = headerImage ?? document.getElementById("coverPhotoPreview");
- if (coverPhotoElement && parseInt(coverPhotoElement.dataset.objectId!) === fileId) {
+ if (headerImage && parseInt(headerImage.dataset.objectId!) === fileId) {
// nothing changed
return;
}
@@ -85,8 +53,7 @@ export function setup(): void {
const file = document.querySelector(
`#wcf\\\\action\\\\UserCoverPhotoAction_coverPhotoFileIDContainer woltlab-core-file[file-id="${fileId}"]`,
);
- const fileUrl = file?.link ?? "";
- const coverPhotoUrl = fileUrl || getDefaultCoverPhoto();
+ const coverPhotoUrl = file?.link || getDefaultCoverPhoto();
if (headerImage && coverPhotoUrl) {
headerImage.src = coverPhotoUrl;
@@ -98,9 +65,6 @@ export function setup(): void {
?.close();
}
- // ACP cover photo management
- updateAcpCoverPhotoPreview(fileId, fileUrl);
-
showDefaultSuccessSnackbar();
fireEvent("com.woltlab.wcf.user", "coverPhoto", {
url: coverPhotoUrl,
diff --git a/wcfsetup/install/files/acp/style/layout.scss b/wcfsetup/install/files/acp/style/layout.scss
index ede9f81303e..08248b1a387 100644
--- a/wcfsetup/install/files/acp/style/layout.scss
+++ b/wcfsetup/install/files/acp/style/layout.scss
@@ -581,21 +581,6 @@ html[data-color-scheme="dark"] {
}
}
-#coverPhotoPreview {
- background: no-repeat center center;
- background-size: cover;
- height: 200px;
- margin-bottom: 5px;
-
- @include screen-xs {
- height: 150px;
- }
-}
-
-#uploadCoverPhoto > .button {
- vertical-align: top;
-}
-
@include screen-md-down {
.pageHeaderSearch {
display: block;
diff --git a/wcfsetup/install/files/acp/templates/userAdd.tpl b/wcfsetup/install/files/acp/templates/userAdd.tpl
index 6e237efbb52..337fb0a3b9e 100644
--- a/wcfsetup/install/files/acp/templates/userAdd.tpl
+++ b/wcfsetup/install/files/acp/templates/userAdd.tpl
@@ -40,11 +40,6 @@
{lang}wcf.user.signature{/lang}
{/if}
- {if $action === 'edit'}
- {lang}wcf.user.avatar{/lang}
- {lang}wcf.user.coverPhoto{/lang}
- {/if}
-
{event name='tabMenuTabs'}
@@ -382,202 +377,6 @@
{/if}
- {if $action == 'edit'}
-
-
-
- {/if}
-
{event name='tabMenuContent'} {* deprecated event *}
{event name='tabMenuContents'}
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/User/Avatar.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/User/Avatar.js
index 5c0f3b9dfc5..92259219a65 100644
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/User/Avatar.js
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/User/Avatar.js
@@ -29,16 +29,13 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Helper/PromiseMutex",
img.classList.add("userAvatarImage");
img.parentElement.classList.add("userAvatar");
});
- const avatarForm = document.getElementById("avatarForm");
- if (avatarForm) {
+ if (document.body.classList.contains("wcfAcp")) {
(0, FileProcessor_1.registerCallback)("wcf\\action\\UserAvatarAction_avatarFileID", (fileId) => {
let link = defaultAvatar;
if (fileId !== undefined) {
const file = document.querySelector(`#wcf\\\\action\\\\UserAvatarAction_avatarFileIDContainer woltlab-core-file[file-id="${fileId}"]`);
link = file.link;
}
- avatarForm.querySelector("img.userAvatarImage").src = link;
- // ACP user profile header
const headerAvatar = document.querySelector(".userProfileHeader__avatar img.userAvatarImage");
if (headerAvatar) {
headerAvatar.src = link;
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/User/CoverPhoto.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/User/CoverPhoto.js
index e17ff61349c..17cf052e4fb 100644
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/User/CoverPhoto.js
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/User/CoverPhoto.js
@@ -7,12 +7,11 @@
* @since 6.2
* @woltlabExcludeBundle all
*/
-define(["require", "exports", "tslib", "WoltLabSuite/Core/Helper/PromiseMutex", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/Component/Dialog", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Core/Form/Builder/Manager", "WoltLabSuite/Core/Event/Handler", "WoltLabSuite/Core/Language", "WoltLabSuite/Core/Dom/Util", "WoltLabSuite/Core/StringUtil", "WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor", "../Snackbar"], function (require, exports, tslib_1, PromiseMutex_1, Selector_1, Dialog_1, Backend_1, FormBuilderManager, Handler_1, Language_1, Util_1, StringUtil_1, FileProcessor_1, Snackbar_1) {
+define(["require", "exports", "tslib", "WoltLabSuite/Core/Helper/PromiseMutex", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/Component/Dialog", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Core/Form/Builder/Manager", "WoltLabSuite/Core/Event/Handler", "WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor", "../Snackbar"], function (require, exports, tslib_1, PromiseMutex_1, Selector_1, Dialog_1, Backend_1, FormBuilderManager, Handler_1, FileProcessor_1, Snackbar_1) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.setup = setup;
FormBuilderManager = tslib_1.__importStar(FormBuilderManager);
- Util_1 = tslib_1.__importDefault(Util_1);
async function editCoverPhoto(button) {
const json = (await (0, Backend_1.prepareRequest)(button.dataset.editCoverPhoto).get().fetchAsJson());
const dialog = (0, Dialog_1.dialogFactory)().fromHtml(json.dialog).withoutControls();
@@ -26,36 +25,15 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Helper/PromiseMutex",
function getDefaultCoverPhoto() {
return document.querySelector("[data-default-cover-photo]")?.dataset.defaultCoverPhoto ?? "";
}
- function updateAcpCoverPhotoPreview(fileId, fileUrl) {
- const coverPhotoPreview = document.getElementById("coverPhotoPreview");
- const coverPhotoNotice = document.getElementById("coverPhotoNotice");
- if (fileUrl) {
- const coverPhotoStyle = `url("${fileUrl}")`;
- if (coverPhotoPreview) {
- coverPhotoPreview.style.backgroundImage = coverPhotoStyle;
- coverPhotoPreview.dataset.objectId = fileId?.toString() || "";
- }
- else if (coverPhotoNotice) {
- coverPhotoNotice.parentElement.appendChild(Util_1.default.createFragmentFromHtml(`
`));
- coverPhotoNotice.remove();
- }
- }
- else if (coverPhotoPreview) {
- coverPhotoPreview.parentElement.appendChild(Util_1.default.createFragmentFromHtml(`${(0, Language_1.getPhrase)("wcf.user.coverPhoto.noImage")} `));
- coverPhotoPreview.remove();
- }
- }
function setup() {
(0, FileProcessor_1.registerCallback)("wcf\\action\\UserCoverPhotoAction_coverPhotoFileID", (fileId) => {
const headerImage = document.querySelector(".userProfileHeader__coverPhotoImage");
- const coverPhotoElement = headerImage ?? document.getElementById("coverPhotoPreview");
- if (coverPhotoElement && parseInt(coverPhotoElement.dataset.objectId) === fileId) {
+ if (headerImage && parseInt(headerImage.dataset.objectId) === fileId) {
// nothing changed
return;
}
const file = document.querySelector(`#wcf\\\\action\\\\UserCoverPhotoAction_coverPhotoFileIDContainer woltlab-core-file[file-id="${fileId}"]`);
- const fileUrl = file?.link ?? "";
- const coverPhotoUrl = fileUrl || getDefaultCoverPhoto();
+ const coverPhotoUrl = file?.link || getDefaultCoverPhoto();
if (headerImage && coverPhotoUrl) {
headerImage.src = coverPhotoUrl;
headerImage.dataset.objectId = fileId?.toString() || "";
@@ -64,8 +42,6 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Helper/PromiseMutex",
?.closest("woltlab-core-dialog")
?.close();
}
- // ACP cover photo management
- updateAcpCoverPhotoPreview(fileId, fileUrl);
(0, Snackbar_1.showDefaultSuccessSnackbar)();
(0, Handler_1.fire)("com.woltlab.wcf.user", "coverPhoto", {
url: coverPhotoUrl,
diff --git a/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php b/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php
index c2cc24a4add..56cbc1d5b10 100755
--- a/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php
@@ -2,16 +2,13 @@
namespace wcf\acp\form;
-use wcf\data\file\File;
use wcf\data\style\Style;
-use wcf\data\user\cover\photo\IUserCoverPhoto;
use wcf\data\user\group\UserGroup;
use wcf\data\user\User;
use wcf\data\user\UserAction;
use wcf\data\user\UserEditor;
use wcf\data\user\UserProfileAction;
use wcf\form\AbstractForm;
-use wcf\system\cache\runtime\FileRuntimeCache;
use wcf\system\cache\runtime\UserProfileRuntimeCache;
use wcf\system\exception\IllegalLinkException;
use wcf\system\exception\PermissionDeniedException;
@@ -24,7 +21,6 @@
use wcf\system\user\multifactor\Setup;
use wcf\system\view\user\profile\AdminUserProfileHeaderView;
use wcf\system\WCF;
-use wcf\util\StringUtil;
/**
* Shows the user edit form.
@@ -57,66 +53,6 @@ class UserEditForm extends UserAddForm
*/
public $user;
- /**
- * user avatar object
- */
- public ?File $userAvatar = null;
-
- /**
- * avatar type
- * @var string
- * @deprecated 6.2 No longer in use.
- */
- public $avatarType = 'none';
-
- /**
- * true to disable this avatar
- * @var int
- */
- public $disableAvatar = 0;
-
- /**
- * reason
- * @var string
- */
- public $disableAvatarReason = '';
-
- /**
- * date when the avatar will be enabled again
- * @var int
- */
- public $disableAvatarExpires = 0;
-
- /**
- * user cover photo object
- * @var ?IUserCoverPhoto
- */
- public $userCoverPhoto;
-
- /**
- * true to disable this cover photo
- * @var int
- */
- public $disableCoverPhoto = 0;
-
- /**
- * reason
- * @var string
- */
- public $disableCoverPhotoReason = '';
-
- /**
- * date when the cover photo will be enabled again
- * @var int
- */
- public $disableCoverPhotoExpires = 0;
-
- /**
- * true to delete the current cover photo
- * @var int
- */
- public $deleteCoverPhoto = 0;
-
/**
* true to delete the current auth data
* @var int
@@ -193,37 +129,6 @@ public function readFormParameters()
$this->colorScheme = $_POST['colorScheme'];
}
- if (WCF::getSession()->hasPermission('admin.user.canDisableAvatar')) {
- if (!empty($_POST['disableAvatar'])) {
- $this->disableAvatar = 1;
- }
- if (isset($_POST['disableAvatarReason'])) {
- $this->disableAvatarReason = StringUtil::trim($_POST['disableAvatarReason']);
- }
- if ($this->disableAvatar !== 0 && !isset($_POST['disableAvatarNeverExpires'])) {
- if (isset($_POST['disableAvatarExpires'])) {
- $this->disableAvatarExpires = @\intval(@\strtotime(StringUtil::trim($_POST['disableAvatarExpires'])));
- }
- }
- }
-
- if (WCF::getSession()->hasPermission('admin.user.canDisableCoverPhoto')) {
- if (isset($_POST['deleteCoverPhoto'])) {
- $this->deleteCoverPhoto = 1;
- }
- if (!empty($_POST['disableCoverPhoto'])) {
- $this->disableCoverPhoto = 1;
- }
- if (isset($_POST['disableCoverPhotoReason'])) {
- $this->disableCoverPhotoReason = StringUtil::trim($_POST['disableCoverPhotoReason']);
- }
- if ($this->disableCoverPhoto !== 0 && !isset($_POST['disableCoverPhotoNeverExpires'])) {
- if (isset($_POST['disableCoverPhotoExpires'])) {
- $this->disableCoverPhotoExpires = @\intval(@\strtotime(StringUtil::trim($_POST['disableCoverPhotoExpires'])));
- }
- }
- }
-
if (WCF::getSession()->hasPermission('admin.user.canEditPassword') && isset($_POST['disconnect3rdParty'])) {
$this->disconnect3rdParty = 1;
}
@@ -251,23 +156,6 @@ public function readData()
}
parent::readData();
-
- // get the avatar object
- if ($this->user->avatarFileID !== null) {
- $this->userAvatar = FileRuntimeCache::getInstance()->getObject($this->user->avatarFileID);
- }
-
- // get the user cover photo object
- if ($this->user->coverPhotoFileID !== null) {
- // If the editing user lacks the permissions to view the cover photo, the system
- // falls back to the default cover photo of the default style, which must not be
- // shown as the user's own cover photo.
- if ($userProfile->canSeeCoverPhoto()) {
- $this->userCoverPhoto = UserProfileRuntimeCache::getInstance()
- ->getObject($this->userID)
- ->getCoverPhoto(true);
- }
- }
}
/**
@@ -299,14 +187,6 @@ protected function readDefaultValues()
$this->disableSignatureReason = $this->user->disableSignatureReason;
$this->disableSignatureExpires = $this->user->disableSignatureExpires;
- $this->disableAvatar = $this->user->disableAvatar;
- $this->disableAvatarReason = $this->user->disableAvatarReason;
- $this->disableAvatarExpires = $this->user->disableAvatarExpires;
-
- $this->disableCoverPhoto = $this->user->disableCoverPhoto;
- $this->disableCoverPhotoReason = $this->user->disableCoverPhotoReason;
- $this->disableCoverPhotoExpires = $this->user->disableCoverPhotoExpires;
-
$this->colorScheme = $this->user->getUserOption('colorScheme') ?? 'system';
}
@@ -326,16 +206,6 @@ public function assignVariables()
'action' => 'edit',
'url' => '',
'user' => $this->user,
- 'avatarType' => $this->avatarType,
- 'disableAvatar' => $this->disableAvatar,
- 'disableAvatarReason' => $this->disableAvatarReason,
- 'disableAvatarExpires' => $this->disableAvatarExpires,
- 'userAvatar' => $this->userAvatar,
- 'userCoverPhoto' => $this->userCoverPhoto,
- 'disableCoverPhoto' => $this->disableCoverPhoto,
- 'disableCoverPhotoReason' => $this->disableCoverPhotoReason,
- 'disableCoverPhotoExpires' => $this->disableCoverPhotoExpires,
- 'deleteCoverPhoto' => $this->deleteCoverPhoto,
'ownerGroupID' => UserGroup::getOwnerGroupID(),
'availableStyles' => $this->availableStyles,
'styleID' => $this->styleID,
@@ -397,20 +267,6 @@ public function save()
$data['data']['disableSignatureExpires'] = $this->disableSignatureExpires;
}
- // handle disabled avatar
- if (WCF::getSession()->hasPermission('admin.user.canDisableAvatar')) {
- $data['data']['disableAvatar'] = $this->disableAvatar;
- $data['data']['disableAvatarReason'] = $this->disableAvatarReason;
- $data['data']['disableAvatarExpires'] = $this->disableAvatarExpires;
- }
-
- // handle disabled cover photo
- if (WCF::getSession()->hasPermission('admin.user.canDisableCoverPhoto')) {
- $data['data']['disableCoverPhoto'] = $this->disableCoverPhoto;
- $data['data']['disableCoverPhotoReason'] = $this->disableCoverPhotoReason;
- $data['data']['disableCoverPhotoExpires'] = $this->disableCoverPhotoExpires;
- }
-
$this->objectAction = new UserAction([$this->userID], 'update', $data);
$this->objectAction->executeAction();
diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml
index b9a03d7ccf8..436af9dd2c5 100644
--- a/wcfsetup/install/lang/de.xml
+++ b/wcfsetup/install/lang/de.xml
@@ -5196,7 +5196,6 @@ Sobald {if LANGUAGE_USE_INFORMAL_VARIANT}dein{else}Ihr{/if} Benutzerkonto freige
- user->coverPhotoFileID} {if LANGUAGE_USE_INFORMAL_VARIANT}dein{else}Ihr{/if} derzeitiges Titelbild gesperrt und{/if} {if LANGUAGE_USE_INFORMAL_VARIANT}dir{else}Ihnen{/if} die weitere Nutzungsberechtigung der Titelbild-Funktion {if !$__wcf->user->disableCoverPhotoReason}entzogen.{else} aus folgenden Gründen entzogen: {$__wcf->user->disableCoverPhotoReason}{/if}]]>
-
-
Maximale Bildgröße: {$coverPhotoDimensions.max.width} × {$coverPhotoDimensions.max.height} Pixel
Maximale Dateigröße: {$__wcf->session->getPermission('user.profile.coverPhoto.maxSize')|filesize}
@@ -5814,5 +5813,6 @@ Erlaubte Dateiendungen: {', '|implode:$allowedFileExtensions}]]>
+
diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml
index d3241bd904f..4639def47b8 100644
--- a/wcfsetup/install/lang/en.xml
+++ b/wcfsetup/install/lang/en.xml
@@ -5189,7 +5189,6 @@ You also received a list of backup codes to use when your second factor becomes
- user->coverPhotoFileID}have blocked your cover photo and {/if}disallowed you from using a cover photo{if $__wcf->user->disableCoverPhotoReason}: {$__wcf->user->disableCoverPhotoReason}{/if}.]]>
-
-
Maximum image size: {$coverPhotoDimensions.max.width} × {$coverPhotoDimensions.max.height} pixels
Maximum file size: {$__wcf->session->getPermission('user.profile.coverPhoto.maxSize')|filesize}
@@ -5816,5 +5815,6 @@ Allowed extensions: {', '|implode:$allowedFileExtensions}]]>
+
From e84a84ef6abac4ddf250a9e1c2f430eb77c3b918 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Tue, 6 Oct 2026 20:15:45 +0200
Subject: [PATCH 043/115] Show 3rd-party login and MFA status in ACP user
profile header
---
.../install/files/acp/templates/userProfileHeader.tpl | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/wcfsetup/install/files/acp/templates/userProfileHeader.tpl b/wcfsetup/install/files/acp/templates/userProfileHeader.tpl
index 9dfbf9e8351..db32a0d6272 100644
--- a/wcfsetup/install/files/acp/templates/userProfileHeader.tpl
+++ b/wcfsetup/install/files/acp/templates/userProfileHeader.tpl
@@ -131,6 +131,16 @@
{icon name='network-wired'} {lang}wcf.user.registrationIpAddress{/lang}
{unsafe:$view->user->getRegistrationIpAddress()|ipSearch}
{/if}
+ {if $__wcf->session->getPermission('admin.user.canEditPassword')}
+ {if !$view->user->authData|empty}
+ {icon name='arrow-right-to-bracket'} {lang}wcf.user.3rdparty{/lang}
+ {lang}wcf.user.3rdparty.{$view->user->getAuthProvider()}{/lang}
+ {/if}
+ {if $view->user->multifactorActive}
+ {icon name='fingerprint'} {lang}wcf.user.security.multifactor{/lang}
+ {lang}wcf.user.security.multifactor.active{/lang}
+ {/if}
+ {/if}
{event name='afterMeta'}
From 9188beabe29ca4f07c63c55e916087955188d95a Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Tue, 6 Oct 2026 20:21:56 +0200
Subject: [PATCH 044/115] Move 3rd-party login disconnect to RPC interaction
---
.../install/files/acp/templates/userAdd.tpl | 15 --------
.../files/lib/acp/form/UserEditForm.class.php | 13 -------
.../user/DisconnectThirdPartyLogin.class.php | 29 ++++++++++++++
.../files/lib/data/user/User.class.php | 13 +++++++
.../users/DisconnectThirdPartyLogin.class.php | 38 +++++++++++++++++++
.../admin/UserInteractions.class.php | 13 +++++++
wcfsetup/install/lang/de.xml | 4 +-
wcfsetup/install/lang/en.xml | 4 +-
8 files changed, 99 insertions(+), 30 deletions(-)
create mode 100644 wcfsetup/install/files/lib/command/user/DisconnectThirdPartyLogin.class.php
create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/users/DisconnectThirdPartyLogin.class.php
diff --git a/wcfsetup/install/files/acp/templates/userAdd.tpl b/wcfsetup/install/files/acp/templates/userAdd.tpl
index 337fb0a3b9e..1cc320de504 100644
--- a/wcfsetup/install/files/acp/templates/userAdd.tpl
+++ b/wcfsetup/install/files/acp/templates/userAdd.tpl
@@ -135,21 +135,6 @@
{if $action == 'add' || $__wcf->session->getPermission('admin.user.canEditPassword')}
- {if $action == 'edit' && !$user->authData|empty}
-
- {/if}
-
{if $action == 'edit' && $user->multifactorActive}
{lang}wcf.acp.user.security.multifactor{/lang}
diff --git a/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php b/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php
index 56cbc1d5b10..b6eeb8cc496 100755
--- a/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php
@@ -53,12 +53,6 @@ class UserEditForm extends UserAddForm
*/
public $user;
- /**
- * true to delete the current auth data
- * @var int
- */
- public $disconnect3rdParty = 0;
-
/**
* true to disable multifactor authentication
* @var int
@@ -129,9 +123,6 @@ public function readFormParameters()
$this->colorScheme = $_POST['colorScheme'];
}
- if (WCF::getSession()->hasPermission('admin.user.canEditPassword') && isset($_POST['disconnect3rdParty'])) {
- $this->disconnect3rdParty = 1;
- }
if (WCF::getSession()->hasPermission('admin.user.canEditPassword') && isset($_POST['multifactorDisable'])) {
$this->multifactorDisable = 1;
}
@@ -221,10 +212,6 @@ public function save()
$this->htmlInputProcessor->setObjectID($this->userID);
MessageEmbeddedObjectManager::getInstance()->registerObjects($this->htmlInputProcessor);
- if ($this->disconnect3rdParty !== 0) {
- $this->additionalFields['authData'] = '';
- }
-
// add default groups
$defaultGroups = UserGroup::getAccessibleGroups([UserGroup::GUESTS, UserGroup::EVERYONE, UserGroup::USERS]);
$oldGroupIDs = $this->user->getGroupIDs(true);
diff --git a/wcfsetup/install/files/lib/command/user/DisconnectThirdPartyLogin.class.php b/wcfsetup/install/files/lib/command/user/DisconnectThirdPartyLogin.class.php
new file mode 100644
index 00000000000..c41781c69b9
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/user/DisconnectThirdPartyLogin.class.php
@@ -0,0 +1,29 @@
+
+ * @since 6.3
+ */
+final class DisconnectThirdPartyLogin
+{
+ public function __construct(
+ private readonly User $user,
+ ) {}
+
+ public function __invoke(): User
+ {
+ $builder = UserBuilder::forUpdate($this->user)
+ ->setAuthData('');
+
+ return (new UpdateUser($builder))();
+ }
+}
diff --git a/wcfsetup/install/files/lib/data/user/User.class.php b/wcfsetup/install/files/lib/data/user/User.class.php
index 84e58e48724..102be5104c3 100644
--- a/wcfsetup/install/files/lib/data/user/User.class.php
+++ b/wcfsetup/install/files/lib/data/user/User.class.php
@@ -670,6 +670,19 @@ public function canSendNewPassword(): bool
&& $this->isManageable();
}
+ /**
+ * Returns true if the active user can remove the connection of this user
+ * to a third party login provider.
+ *
+ * @since 6.3
+ */
+ public function canDisconnectThirdPartyLogin(): bool
+ {
+ return WCF::getSession()->hasPermission('admin.user.canEditPassword')
+ && $this->authData !== ''
+ && $this->isManageable();
+ }
+
/**
* Returns true if the active user can resend the activation mail to this user.
*
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/DisconnectThirdPartyLogin.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/DisconnectThirdPartyLogin.class.php
new file mode 100644
index 00000000000..f0656f471da
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/DisconnectThirdPartyLogin.class.php
@@ -0,0 +1,38 @@
+
+ * @since 6.3
+ */
+#[PostRequest('/core/users/{id:\d+}/disconnect-third-party-login')]
+final class DisconnectThirdPartyLogin implements IController
+{
+ #[\Override]
+ public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface
+ {
+ $user = Helper::fetchObjectFromRequestParameter($variables['id'], User::class);
+
+ if (!$user->canDisconnectThirdPartyLogin()) {
+ throw new PermissionDeniedException();
+ }
+
+ new \wcf\command\user\DisconnectThirdPartyLogin($user)();
+
+ return new JsonResponse([]);
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/interaction/admin/UserInteractions.class.php b/wcfsetup/install/files/lib/system/interaction/admin/UserInteractions.class.php
index 3f77224e1d3..9649298c6d0 100644
--- a/wcfsetup/install/files/lib/system/interaction/admin/UserInteractions.class.php
+++ b/wcfsetup/install/files/lib/system/interaction/admin/UserInteractions.class.php
@@ -21,6 +21,7 @@
use wcf\system\interaction\LinkInteraction;
use wcf\system\interaction\RpcInteraction;
use wcf\system\request\LinkHandler;
+use wcf\system\WCF;
/**
* Interaction provider for the user administration.
@@ -158,6 +159,18 @@ public function __construct()
'wcf.acp.user.action.sendNewPassword.confirmMessage',
static fn(User $user) => $user->canSendNewPassword()
),
+ new RpcInteraction(
+ 'disconnect-third-party-login',
+ 'core/users/%s/disconnect-third-party-login',
+ 'wcf.acp.user.action.disconnectThirdPartyLogin',
+ InteractionConfirmationType::Custom,
+ static fn(User $user) => WCF::getLanguage()->getDynamicVariable(
+ 'wcf.acp.user.action.disconnectThirdPartyLogin.confirmMessage',
+ ['user' => $user]
+ ),
+ static fn(User $user) => $user->canDisconnectThirdPartyLogin(),
+ InteractionEffect::ReloadPage
+ ),
new LinkInteraction(
'export-gdpr',
UserExportGdprAction::class,
diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml
index 436af9dd2c5..d63573f7e16 100644
--- a/wcfsetup/install/lang/de.xml
+++ b/wcfsetup/install/lang/de.xml
@@ -3230,6 +3230,8 @@ freigeschaltet. {if LANGUAGE_USE_INFORMAL_VARIANT}Du kannst{else}Sie können{/if
+
+ - getAuthProvider()|language} wirklich trennen? Der Benutzer kann sich anschließend nur noch mit einem Kennwort anmelden.]]>
@@ -5131,7 +5133,6 @@ Sobald {if LANGUAGE_USE_INFORMAL_VARIANT}dein{else}Ihr{/if} Benutzerkonto freige
- - getAuthProvider()|language} verknüpft.]]>
@@ -5814,5 +5815,6 @@ Erlaubte Dateiendungen: {', '|implode:$allowedFileExtensions}]]>
+
diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml
index 4639def47b8..c1c0782eb18 100644
--- a/wcfsetup/install/lang/en.xml
+++ b/wcfsetup/install/lang/en.xml
@@ -3155,6 +3155,8 @@ Your account on {@PAGE_TITLE|phrase} [URL:{link isEmail=true}{/link}] has been a
+
+ - getAuthProvider()|language}? Afterwards, the user can only log in with a password.]]>
@@ -5124,7 +5126,6 @@ You also received a list of backup codes to use when your second factor becomes
- - getAuthProvider()|language}.]]>
@@ -5816,5 +5817,6 @@ Allowed extensions: {', '|implode:$allowedFileExtensions}]]>
+
From ea36084ec30786e350e2a9c102e3e5adb23ff559 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Tue, 6 Oct 2026 20:26:07 +0200
Subject: [PATCH 045/115] Move MFA disable to RPC interaction with
manageability check
---
.../install/files/acp/templates/userAdd.tpl | 28 -------------
.../files/lib/acp/form/UserEditForm.class.php | 25 ------------
.../user/DisableUserMultifactor.class.php | 39 +++++++++++++++++++
.../files/lib/data/user/User.class.php | 13 +++++++
.../users/DisableUserMultifactor.class.php | 38 ++++++++++++++++++
.../admin/UserInteractions.class.php | 12 ++++++
wcfsetup/install/lang/de.xml | 13 ++++---
wcfsetup/install/lang/en.xml | 13 ++++---
8 files changed, 116 insertions(+), 65 deletions(-)
create mode 100644 wcfsetup/install/files/lib/command/user/DisableUserMultifactor.class.php
create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/users/DisableUserMultifactor.class.php
diff --git a/wcfsetup/install/files/acp/templates/userAdd.tpl b/wcfsetup/install/files/acp/templates/userAdd.tpl
index 1cc320de504..49161f2cfef 100644
--- a/wcfsetup/install/files/acp/templates/userAdd.tpl
+++ b/wcfsetup/install/files/acp/templates/userAdd.tpl
@@ -134,34 +134,6 @@
{event name='generalFields'}
- {if $action == 'add' || $__wcf->session->getPermission('admin.user.canEditPassword')}
- {if $action == 'edit' && $user->multifactorActive}
-
- {/if}
- {/if}
-
{event name='sections'}
diff --git a/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php b/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php
index b6eeb8cc496..a11bb91e427 100755
--- a/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php
@@ -18,7 +18,6 @@
use wcf\system\moderation\queue\ModerationQueueManager;
use wcf\system\style\StyleHandler;
use wcf\command\user\SetColorScheme;
-use wcf\system\user\multifactor\Setup;
use wcf\system\view\user\profile\AdminUserProfileHeaderView;
use wcf\system\WCF;
@@ -53,12 +52,6 @@ class UserEditForm extends UserAddForm
*/
public $user;
- /**
- * true to disable multifactor authentication
- * @var int
- */
- public $multifactorDisable = 0;
-
/**
* list of available styles for the edited user
* @var Style[]
@@ -122,10 +115,6 @@ public function readFormParameters()
if (isset($_POST['colorScheme'])) {
$this->colorScheme = $_POST['colorScheme'];
}
-
- if (WCF::getSession()->hasPermission('admin.user.canEditPassword') && isset($_POST['multifactorDisable'])) {
- $this->multifactorDisable = 1;
- }
}
#[\Override]
@@ -257,20 +246,6 @@ public function save()
$this->objectAction = new UserAction([$this->userID], 'update', $data);
$this->objectAction->executeAction();
- // disable multifactor authentication
- if (WCF::getSession()->hasPermission('admin.user.canEditPassword') && $this->multifactorDisable !== 0) {
- WCF::getDB()->beginTransaction();
- $setups = Setup::getAllForUser($this->user->getDecoratedObject());
- foreach ($setups as $setup) {
- $setup->delete();
- }
-
- $this->user->update([
- 'multifactorActive' => 0,
- ]);
- WCF::getDB()->commitTransaction();
- }
-
if ($this->user->getUserOption('colorScheme') !== $this->colorScheme) {
new SetColorScheme($this->user->getDecoratedObject(), $this->colorScheme)();
}
diff --git a/wcfsetup/install/files/lib/command/user/DisableUserMultifactor.class.php b/wcfsetup/install/files/lib/command/user/DisableUserMultifactor.class.php
new file mode 100644
index 00000000000..f73954c69b7
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/user/DisableUserMultifactor.class.php
@@ -0,0 +1,39 @@
+
+ * @since 6.3
+ */
+final class DisableUserMultifactor
+{
+ public function __construct(
+ private readonly User $user,
+ ) {}
+
+ public function __invoke(): void
+ {
+ // The user is updated directly instead of using `UpdateUser` to keep
+ // event listeners from running inside the transaction.
+ WCF::getDB()->beginTransaction();
+ foreach (Setup::getAllForUser($this->user) as $setup) {
+ $setup->delete();
+ }
+
+ (new UserEditor($this->user))->update([
+ 'multifactorActive' => 0,
+ ]);
+ WCF::getDB()->commitTransaction();
+ }
+}
diff --git a/wcfsetup/install/files/lib/data/user/User.class.php b/wcfsetup/install/files/lib/data/user/User.class.php
index 102be5104c3..f569d025be5 100644
--- a/wcfsetup/install/files/lib/data/user/User.class.php
+++ b/wcfsetup/install/files/lib/data/user/User.class.php
@@ -683,6 +683,19 @@ public function canDisconnectThirdPartyLogin(): bool
&& $this->isManageable();
}
+ /**
+ * Returns true if the active user can completely disable the multi-factor
+ * authentication of this user.
+ *
+ * @since 6.3
+ */
+ public function canDisableMultifactor(): bool
+ {
+ return WCF::getSession()->hasPermission('admin.user.canEditPassword')
+ && $this->multifactorActive !== 0
+ && $this->isManageable();
+ }
+
/**
* Returns true if the active user can resend the activation mail to this user.
*
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/DisableUserMultifactor.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/DisableUserMultifactor.class.php
new file mode 100644
index 00000000000..f39f86f3f1d
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/DisableUserMultifactor.class.php
@@ -0,0 +1,38 @@
+
+ * @since 6.3
+ */
+#[PostRequest('/core/users/{id:\d+}/disable-multifactor')]
+final class DisableUserMultifactor implements IController
+{
+ #[\Override]
+ public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface
+ {
+ $user = Helper::fetchObjectFromRequestParameter($variables['id'], User::class);
+
+ if (!$user->canDisableMultifactor()) {
+ throw new PermissionDeniedException();
+ }
+
+ new \wcf\command\user\DisableUserMultifactor($user)();
+
+ return new JsonResponse([]);
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/interaction/admin/UserInteractions.class.php b/wcfsetup/install/files/lib/system/interaction/admin/UserInteractions.class.php
index 9649298c6d0..4682a1803d5 100644
--- a/wcfsetup/install/files/lib/system/interaction/admin/UserInteractions.class.php
+++ b/wcfsetup/install/files/lib/system/interaction/admin/UserInteractions.class.php
@@ -171,6 +171,18 @@ public function __construct()
static fn(User $user) => $user->canDisconnectThirdPartyLogin(),
InteractionEffect::ReloadPage
),
+ new RpcInteraction(
+ 'disable-multifactor',
+ 'core/users/%s/disable-multifactor',
+ 'wcf.acp.user.action.disableMultifactor',
+ InteractionConfirmationType::Custom,
+ static fn(User $user) => WCF::getLanguage()->getDynamicVariable(
+ 'wcf.acp.user.action.disableMultifactor.confirmMessage',
+ ['user' => $user]
+ ),
+ static fn(User $user) => $user->canDisableMultifactor(),
+ InteractionEffect::ReloadPage
+ ),
new LinkInteraction(
'export-gdpr',
UserExportGdprAction::class,
diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml
index d63573f7e16..b156b24cc10 100644
--- a/wcfsetup/install/lang/de.xml
+++ b/wcfsetup/install/lang/de.xml
@@ -3232,6 +3232,8 @@ freigeschaltet. {if LANGUAGE_USE_INFORMAL_VARIANT}Du kannst{else}Sie können{/if
- getAuthProvider()|language} wirklich trennen? Der Benutzer kann sich anschließend nur noch mit einem Kennwort anmelden.]]>
+
+ - username}“ wirklich vollständig deaktivieren? Der Benutzer muss sie anschließend erneut einrichten. {if LANGUAGE_USE_INFORMAL_VARIANT}Stelle sicher, dass du den Benutzer zuvor ausreichend authentifiziert hast.{else}Stellen Sie sicher, dass Sie den Benutzer zuvor ausreichend authentifiziert haben.{/if}]]>
@@ -3249,12 +3251,6 @@ freigeschaltet. {if LANGUAGE_USE_INFORMAL_VARIANT}Du kannst{else}Sie können{/if
-
-
-
-
- - {$user->username} vollständig. Der Benutzer muss die Mehrfaktor-Authentifizierung anschließend erneut einrichten.
-Achtung: Die Mehrfaktor-Authentifizierung wird von Benutzern aktiv eingerichtet, um das eigene Benutzerkonto besser zu schützen. {if LANGUAGE_USE_INFORMAL_VARIANT}Stelle sicher, dass du den Benutzer ausreichend authentifizierst, bevor du die Mehrfaktor-Authentifizierung deaktivierst.{else}Stellen Sie sicher, dass Sie den Benutzer ausreichend authentifizieren, bevor Sie die Mehrfaktor-Authentifizierung deaktivieren.{/if}]]>
@@ -5816,5 +5812,10 @@ Erlaubte Dateiendungen: {', '|implode:$allowedFileExtensions}]]>
+
+
+
+
+
diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml
index c1c0782eb18..46ff4d100c3 100644
--- a/wcfsetup/install/lang/en.xml
+++ b/wcfsetup/install/lang/en.xml
@@ -3157,6 +3157,8 @@ Your account on {@PAGE_TITLE|phrase} [URL:{link isEmail=true}{/link}] has been a
- getAuthProvider()|language}? Afterwards, the user can only log in with a password.]]>
+
+ - username}”? The user will need to set it up from scratch. Please make sure to properly authenticate the user beforehand.]]>
@@ -3174,12 +3176,6 @@ Your account on {@PAGE_TITLE|phrase} [URL:{link isEmail=true}{/link}] has been a
-
-
-
-
- - {$user->username}. The user will need to setup up multi-factor authentication from scratch if this checkbox is checked.
-Attention : Multi-factor authentication is actively set up by users to better protect their accounts. Please make sure to properly authenticate the user before disabling multi-factor authentication for their account.]]>
@@ -5818,5 +5814,10 @@ Allowed extensions: {', '|implode:$allowedFileExtensions}]]>
+
+
+
+
+
From 6d12bc2f085746c3baa0d6c2cd1d528622af34fa Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Tue, 6 Oct 2026 20:37:01 +0200
Subject: [PATCH 046/115] Remove legacy signature-block fields from ACP user
add/edit forms
---
phpstan-baseline.neon | 2 +-
.../install/files/acp/templates/userAdd.tpl | 63 -------------------
.../files/lib/acp/form/UserAddForm.class.php | 44 +------------
.../files/lib/acp/form/UserEditForm.class.php | 10 ---
4 files changed, 2 insertions(+), 117 deletions(-)
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
index 7140ec41eb5..b9e44054a5c 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -172,7 +172,7 @@ parameters:
-
message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
identifier: empty.notAllowed
- count: 5
+ count: 4
path: wcfsetup/install/files/lib/acp/form/UserAddForm.class.php
-
diff --git a/wcfsetup/install/files/acp/templates/userAdd.tpl b/wcfsetup/install/files/acp/templates/userAdd.tpl
index 49161f2cfef..77680e111dc 100644
--- a/wcfsetup/install/files/acp/templates/userAdd.tpl
+++ b/wcfsetup/install/files/acp/templates/userAdd.tpl
@@ -267,69 +267,6 @@
{event name='signatureFields'}
- {if $__wcf->session->getPermission('admin.user.canDisableSignature')}
-
-
-
- {/if}
-
{event name='signatureFieldsets'}
{/if}
diff --git a/wcfsetup/install/files/lib/acp/form/UserAddForm.class.php b/wcfsetup/install/files/lib/acp/form/UserAddForm.class.php
index 34e59f17a1f..15dd82192f5 100644
--- a/wcfsetup/install/files/lib/acp/form/UserAddForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/UserAddForm.class.php
@@ -94,24 +94,6 @@ class UserAddForm extends UserOptionListForm
*/
public $signature = '';
- /**
- * true to disable this signature
- * @var int
- */
- public $disableSignature = 0;
-
- /**
- * reason
- * @var string
- */
- public $disableSignatureReason = '';
-
- /**
- * date when the signature will be enabled again
- * @var int
- */
- public $disableSignatureExpires = 0;
-
/**
* tree of available user options
* @var mixed[]
@@ -186,20 +168,6 @@ public function readFormParameters()
if (isset($_POST['signature'])) {
$this->signature = StringUtil::trim($_POST['signature']);
}
-
- if (WCF::getSession()->hasPermission('admin.user.canDisableSignature')) {
- if (isset($_POST['disableSignatureReason'])) {
- $this->disableSignatureReason = StringUtil::trim($_POST['disableSignatureReason']);
- }
- if (!empty($_POST['disableSignature'])) {
- $this->disableSignature = 1;
- }
- if ($this->disableSignature !== 0 && !isset($_POST['disableSignatureNeverExpires'])) {
- if (isset($_POST['disableSignatureExpires'])) {
- $this->disableSignatureExpires = @\intval(@\strtotime(StringUtil::trim($_POST['disableSignatureExpires'])));
- }
- }
- }
}
#[\Override]
@@ -318,12 +286,6 @@ public function save()
'signatureAttachmentHandler' => $this->attachmentHandler,
];
- if (WCF::getSession()->hasPermission('admin.user.canDisableSignature')) {
- $data['data']['disableSignature'] = $this->disableSignature;
- $data['data']['disableSignatureReason'] = $this->disableSignatureReason;
- $data['data']['disableSignatureExpires'] = $this->disableSignatureExpires;
- }
-
$this->objectAction = new UserAction([], 'create', $data);
$returnValues = $this->objectAction->executeAction();
@@ -343,9 +305,8 @@ public function save()
]);
// reset values
- $this->disableSignature = $this->disableSignatureExpires = 0;
$this->username = $this->email = $this->password = $this->userTitle = '';
- $this->signature = $this->disableSignatureReason = '';
+ $this->signature = '';
$this->groupIDs = [];
$this->languageID = $this->getDefaultFormLanguageID();
$this->optionHandler->resetOptionValues();
@@ -468,9 +429,6 @@ public function assignVariables()
'action' => 'add',
'userTitle' => $this->userTitle,
'signature' => $this->signature,
- 'disableSignature' => $this->disableSignature,
- 'disableSignatureReason' => $this->disableSignatureReason,
- 'disableSignatureExpires' => $this->disableSignatureExpires,
'attachmentHandler' => $this->attachmentHandler,
'attachmentObjectID' => $this->attachmentObjectID,
'attachmentObjectType' => $this->attachmentObjectType,
diff --git a/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php b/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php
index a11bb91e427..d7d88b901cc 100755
--- a/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php
@@ -163,9 +163,6 @@ protected function readDefaultValues()
$this->styleID = $this->user->styleID;
$this->signature = $this->user->signature;
- $this->disableSignature = $this->user->disableSignature;
- $this->disableSignatureReason = $this->user->disableSignatureReason;
- $this->disableSignatureExpires = $this->user->disableSignatureExpires;
$this->colorScheme = $this->user->getUserOption('colorScheme') ?? 'system';
}
@@ -236,13 +233,6 @@ public function save()
$data['data']['oldUsername'] = $this->user->username;
}
- // handle disabled signature
- if (WCF::getSession()->hasPermission('admin.user.canDisableSignature')) {
- $data['data']['disableSignature'] = $this->disableSignature;
- $data['data']['disableSignatureReason'] = $this->disableSignatureReason;
- $data['data']['disableSignatureExpires'] = $this->disableSignatureExpires;
- }
-
$this->objectAction = new UserAction([$this->userID], 'update', $data);
$this->objectAction->executeAction();
From 1499e88d7c7b4da5cbfbb7bd6bef062d8569ac36 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Wed, 7 Oct 2026 11:32:29 +0200
Subject: [PATCH 047/115] Migrate ACP user add/edit form to form builder
---
phpstan-baseline.neon | 12 -
.../install/files/acp/templates/userAdd.tpl | 288 +------
.../files/lib/acp/form/UserAddForm.class.php | 771 ++++++++++--------
.../files/lib/acp/form/UserEditForm.class.php | 304 +------
.../lib/command/user/UpdateUser.class.php | 16 +
.../files/lib/data/user/UserBuilder.class.php | 48 ++
.../field/user/UsernameFormField.class.php | 33 +
wcfsetup/install/lang/de.xml | 5 +-
wcfsetup/install/lang/en.xml | 5 +-
9 files changed, 551 insertions(+), 931 deletions(-)
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
index b9e44054a5c..478c80f92ca 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -169,18 +169,6 @@ parameters:
count: 4
path: wcfsetup/install/files/lib/acp/form/TrophyEditForm.class.php
- -
- message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
- identifier: empty.notAllowed
- count: 4
- path: wcfsetup/install/files/lib/acp/form/UserAddForm.class.php
-
- -
- message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
- identifier: empty.notAllowed
- count: 2
- path: wcfsetup/install/files/lib/acp/form/UserEditForm.class.php
-
-
message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
identifier: empty.notAllowed
diff --git a/wcfsetup/install/files/acp/templates/userAdd.tpl b/wcfsetup/install/files/acp/templates/userAdd.tpl
index 77680e111dc..523817325b6 100644
--- a/wcfsetup/install/files/acp/templates/userAdd.tpl
+++ b/wcfsetup/install/files/acp/templates/userAdd.tpl
@@ -18,292 +18,12 @@
{if $action === 'edit'}
{unsafe:$userProfileHeaderView}
-{/if}
-{if $userID|isset && $__wcf->user->userID == $userID}
- {lang}wcf.acp.user.edit.warning.selfEdit{/lang}
+ {if $userID == $__wcf->user->userID}
+ {lang}wcf.acp.user.edit.warning.selfEdit{/lang}
+ {/if}
{/if}
-{include file='shared_formNotice'}
-
-
-
-{if $action === 'edit' && $ownerGroupID}
-
-{/if}
+{unsafe:$form->getHtml()}
{include file='footer'}
diff --git a/wcfsetup/install/files/lib/acp/form/UserAddForm.class.php b/wcfsetup/install/files/lib/acp/form/UserAddForm.class.php
index 15dd82192f5..ebcbd0520da 100644
--- a/wcfsetup/install/files/lib/acp/form/UserAddForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/UserAddForm.class.php
@@ -2,35 +2,55 @@
namespace wcf\acp\form;
-use wcf\data\smiley\category\SmileyCategory;
-use wcf\data\smiley\Smiley;
-use wcf\data\smiley\SmileyCache;
+use wcf\command\user\CreateUser;
+use wcf\command\user\UpdateUser;
+use wcf\data\DatabaseObjectBuilder;
+use wcf\data\language\Language;
+use wcf\data\style\Style;
use wcf\data\user\group\UserGroup;
+use wcf\data\user\option\UserOption;
use wcf\data\user\User;
-use wcf\data\user\UserAction;
-use wcf\form\AbstractForm;
-use wcf\system\attachment\AttachmentHandler;
-use wcf\system\bbcode\BBCodeHandler;
-use wcf\system\database\util\PreparedStatementConditionBuilder;
-use wcf\system\exception\UserInputException;
-use wcf\system\html\input\HtmlInputProcessor;
+use wcf\data\user\UserBuilder;
+use wcf\data\user\UserProfile;
+use wcf\form\AbstractDatabaseObjectBuilderForm;
+use wcf\system\form\builder\container\FormContainer;
+use wcf\system\form\builder\container\TabFormContainer;
+use wcf\system\form\builder\container\TabMenuFormContainer;
+use wcf\system\form\builder\container\wysiwyg\WysiwygFormContainer;
+use wcf\system\form\builder\field\AbstractFormField;
+use wcf\system\form\builder\field\EmailFormField;
+use wcf\system\form\builder\field\IFormField;
+use wcf\system\form\builder\field\MultipleSelectionFormField;
+use wcf\system\form\builder\field\PasswordFormField;
+use wcf\system\form\builder\field\SelectFormField;
+use wcf\system\form\builder\field\TextFormField;
+use wcf\system\form\builder\field\user\UsernameFormField;
+use wcf\system\form\builder\field\validation\FormFieldValidationError;
+use wcf\system\form\builder\field\validation\FormFieldValidator;
+use wcf\system\form\builder\field\wysiwyg\WysiwygAttachmentFormField;
+use wcf\system\form\builder\field\wysiwyg\WysiwygFormField;
+use wcf\system\form\option\user\TUserOptionFormFields;
use wcf\system\language\LanguageFactory;
-use wcf\system\message\embedded\object\MessageEmbeddedObjectManager;
-use wcf\system\request\LinkHandler;
+use wcf\system\style\FontAwesomeIcon;
+use wcf\system\style\StyleHandler;
use wcf\system\WCF;
-use wcf\util\ArrayUtil;
use wcf\util\StringUtil;
-use wcf\util\UserUtil;
/**
* Shows the user add form.
*
- * @author Marcel Werk
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License
+ * @author Marcel Werk
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ *
+ * @extends AbstractDatabaseObjectBuilderForm
*/
-class UserAddForm extends UserOptionListForm
+class UserAddForm extends AbstractDatabaseObjectBuilderForm
{
+ use TUserOptionFormFields {
+ getUserOptionFormField as getDefaultUserOptionFormField;
+ }
+
/**
* @inheritDoc
*/
@@ -42,410 +62,477 @@ class UserAddForm extends UserOptionListForm
public $neededPermissions = ['admin.user.canAddUser'];
/**
- * username
- * @var string
+ * @inheritDoc
*/
- public $username = '';
+ public string $objectEditLinkController = UserEditForm::class;
/**
- * email address
- * @var string
+ * editable options grouped by the name of the top-level category and the
+ * name of their category
+ * @var array>
*/
- public $email = '';
+ protected array $options = [];
- /**
- * user password
- * @var ?string
- */
- public $password = '';
+ #[\Override]
+ public function readParameters(): void
+ {
+ parent::readParameters();
- /**
- * user group ids
- * @var int[]
- */
- public $groupIDs = [];
+ foreach (['profile', 'settings'] as $categoryName) {
+ $this->options[$categoryName] = $this->getUserOptionsByCategory(
+ $categoryName,
+ $this->isEditableOption(...)
+ );
+ }
+ }
- /**
- * @var ?HtmlInputProcessor
- */
- public $htmlInputProcessor;
+ #[\Override]
+ protected function getDatabaseObjectBuilder(): UserBuilder
+ {
+ if ($this->formObject !== null) {
+ return UserBuilder::forUpdate($this->formObject);
+ }
- /**
- * language id
- * @var int
- */
- public $languageID = 0;
+ return UserBuilder::forCreate()
+ ->setRegistrationDate(\TIME_NOW);
+ }
- /**
- * visible languages
- * @var int[]
- */
- public $visibleLanguages = [];
+ #[\Override]
+ protected function getCommand(DatabaseObjectBuilder $builder): callable
+ {
+ if ($this->formObject !== null) {
+ return new UpdateUser($builder);
+ }
- /**
- * title of the user
- * @var string
- */
- protected $userTitle = '';
+ return new CreateUser($builder);
+ }
- /**
- * signature text
- * @var string
- */
- public $signature = '';
+ #[\Override]
+ protected function createForm(): void
+ {
+ parent::createForm();
- /**
- * tree of available user options
- * @var mixed[]
- */
- public $optionTree = [];
+ $tabMenu = TabMenuFormContainer::create('userTabs');
+ $tabMenu->appendChild(
+ TabFormContainer::create('generalTab')
+ ->label('wcf.global.form.data')
+ ->appendChildren($this->getGeneralFormContainers())
+ );
- public AttachmentHandler $attachmentHandler;
+ foreach ($this->options as $categoryName => $options) {
+ if ($options === []) {
+ continue;
+ }
- public int $attachmentObjectID = 0;
+ $tabMenu->appendChild(
+ TabFormContainer::create($categoryName . 'Tab')
+ ->label('wcf.user.option.category.' . $categoryName)
+ ->appendChildren($this->getUserOptionFormContainers($options))
+ );
+ }
- public string $attachmentObjectType = 'com.woltlab.wcf.user.signature';
+ if (\MODULE_USER_SIGNATURE !== 0) {
+ $tabMenu->appendChild(
+ TabFormContainer::create('signatureTab')
+ ->label('wcf.user.signature')
+ ->appendChild($this->getSignatureFormContainer())
+ );
+ }
- /**
- * @var array
- */
- public array $defaultSmilies = [];
+ $this->form->appendChild($tabMenu);
+ }
/**
- * list of smiley categories
- * @var array
+ * Returns the form containers of the general tab.
+ *
+ * @return FormContainer[]
*/
- public array $smileyCategories = [];
-
- public ?string $tmpHash = '';
-
- #[\Override]
- public function readParameters()
+ protected function getGeneralFormContainers(): array
{
- parent::readParameters();
+ $containers = [
+ FormContainer::create('general')
+ ->appendChildren(\array_values(\array_filter([
+ $this->getUsernameFormField(),
+ $this->getEmailFormField(),
+ $this->getPasswordFormField(),
+ $this->getUserTitleFormField(),
+ $this->getGroupsFormField(),
+ ]))),
+ ];
- if (isset($_REQUEST['tmpHash'])) {
- $this->tmpHash = $_REQUEST['tmpHash'];
+ $availableLanguages = LanguageFactory::getInstance()->getLanguages();
+ if (\count($availableLanguages) > 1) {
+ $containers[] = FormContainer::create('languageSection')
+ ->label('wcf.user.language')
+ ->appendChildren($this->getLanguageFormFields($availableLanguages));
}
- if (empty($this->tmpHash)) {
- /** @deprecated 5.5 see QuickReplyManager::setTmpHash() */
- $this->tmpHash = WCF::getSession()->getVar('__wcfAttachmentTmpHash');
- if ($this->tmpHash === null) {
- $this->tmpHash = StringUtil::getRandomID();
- } else {
- WCF::getSession()->unregister('__wcfAttachmentTmpHash');
- }
+
+ if ($this->formObject !== null) {
+ $containers[] = FormContainer::create('styleSection')
+ ->label('wcf.user.styles')
+ ->appendChildren($this->getStyleFormFields());
}
+
+ return $containers;
}
- #[\Override]
- public function readFormParameters()
+ protected function getUsernameFormField(): UsernameFormField
{
- parent::readFormParameters();
+ return UsernameFormField::create()
+ ->required()
+ ->autoFocus()
+ ->exceptUser($this->formObject)
+ ->saveValueCallback(function (UserBuilder $builder, UsernameFormField $field) {
+ $username = $field->getSaveValue();
+ $builder->setUsername($username);
+
+ if (
+ $this->formObject !== null
+ && \mb_strtolower($username) !== \mb_strtolower($this->formObject->username)
+ ) {
+ $builder->setOldUsername($this->formObject->username);
+ $builder->setLastUsernameChange(\TIME_NOW);
+ }
+ });
+ }
- if (isset($_POST['username'])) {
- $this->username = StringUtil::trim($_POST['username']);
- }
- if (isset($_POST['email'])) {
- $this->email = StringUtil::trim($_POST['email']);
- }
- if (isset($_POST['password'])) {
- $this->password = $_POST['password'];
- }
- if (isset($_POST['groupIDs']) && \is_array($_POST['groupIDs'])) {
- $this->groupIDs = ArrayUtil::toIntegerArray($_POST['groupIDs']);
- }
- if (isset($_POST['visibleLanguages']) && \is_array($_POST['visibleLanguages'])) {
- $this->visibleLanguages = ArrayUtil::toIntegerArray($_POST['visibleLanguages']);
- }
- if (isset($_POST['languageID'])) {
- $this->languageID = \intval($_POST['languageID']);
- }
- if (isset($_POST['userTitle'])) {
- $this->userTitle = $_POST['userTitle'];
- }
+ protected function getEmailFormField(): EmailFormField
+ {
+ return EmailFormField::create('email')
+ ->label('wcf.user.email')
+ ->required()
+ ->available(
+ $this->formObject === null
+ || WCF::getSession()->hasPermission('admin.user.canEditMailAddress')
+ )
+ ->addValidator(new FormFieldValidator('notUnique', function (EmailFormField $field) {
+ $email = (string)$field->getValue();
+ if (
+ $this->formObject !== null
+ && \mb_strtolower($email) === \mb_strtolower($this->formObject->email)
+ ) {
+ return;
+ }
- if (isset($_POST['signature'])) {
- $this->signature = StringUtil::trim($_POST['signature']);
- }
+ if (!User::getUserByEmail($email)->isGuest()) {
+ $field->addValidationError(new FormFieldValidationError(
+ 'notUnique',
+ 'wcf.user.email.error.notUnique'
+ ));
+ }
+ }))
+ ->saveValueCallback(static function (UserBuilder $builder, EmailFormField $field) {
+ $builder->setEmail($field->getSaveValue());
+ });
}
- #[\Override]
- public function validate()
+ protected function getPasswordFormField(): ?PasswordFormField
{
- // validate static user options
- try {
- $this->validateUsername($this->username);
- } catch (UserInputException $e) {
- $this->errorType[$e->getField()] = $e->getType();
+ if (
+ $this->formObject !== null
+ && (
+ !WCF::getSession()->hasPermission('admin.user.canEditPassword')
+ || $this->formObject->authData !== ''
+ )
+ ) {
+ return null;
}
- try {
- $this->validateEmail($this->email);
- } catch (UserInputException $e) {
- $this->errorType[$e->getField()] = $e->getType();
+ $staticDictionary = [WCF::getUser()->username, WCF::getUser()->email];
+ if ($this->formObject !== null) {
+ $staticDictionary[] = $this->formObject->username;
+ $staticDictionary[] = $this->formObject->email;
}
- try {
- $this->validatePassword($this->password);
- } catch (UserInputException $e) {
- $this->errorType[$e->getField()] = $e->getType();
+ return PasswordFormField::create('password')
+ ->label('wcf.user.password')
+ ->required($this->formObject === null)
+ ->autoComplete('new-password')
+ ->passwordStrengthMeter()
+ ->addMeterRelatedFieldId('username')
+ ->addMeterRelatedFieldId('email')
+ ->addStaticDictionary($staticDictionary)
+ ->loadValueCallback(static function (User $user, PasswordFormField $field) {
+ // The stored password hash must never be used as the value.
+ })
+ ->saveValueCallback(static function (UserBuilder $builder, PasswordFormField $field) {
+ $password = (string)$field->getSaveValue();
+ if ($password !== '') {
+ $builder->setPassword($password);
+ }
+ });
+ }
+
+ protected function getUserTitleFormField(): ?TextFormField
+ {
+ if (\MODULE_USER_RANK === 0) {
+ return null;
}
- // validate user groups
- if ($this->groupIDs !== []) {
- $conditions = new PreparedStatementConditionBuilder();
- $conditions->add("groupID IN (?)", [$this->groupIDs]);
- $conditions->add("groupType NOT IN (?)", [[UserGroup::GUESTS, UserGroup::EVERYONE, UserGroup::USERS]]);
-
- $sql = "SELECT groupID
- FROM wcf1_user_group
- " . $conditions;
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute($conditions->getParameters());
- $this->groupIDs = [];
- while ($row = $statement->fetchArray()) {
- if (UserGroup::isAccessibleGroup([$row['groupID']])) {
- $this->groupIDs[] = $row['groupID'];
+ return TextFormField::create('userTitle')
+ ->label('wcf.user.userTitle')
+ ->description('wcf.user.userTitle.description')
+ ->maximumLength(\USER_TITLE_MAX_LENGTH)
+ ->addValidator(new FormFieldValidator(
+ 'forbidden',
+ static function (TextFormField $field) {
+ $userTitle = StringUtil::trim((string)$field->getValue());
+ if (!StringUtil::executeWordFilter($userTitle, \USER_FORBIDDEN_TITLES)) {
+ $field->addValidationError(new FormFieldValidationError(
+ 'forbidden',
+ 'wcf.user.userTitle.error.forbidden'
+ ));
+ }
}
- }
- }
+ ))
+ ->saveValueCallback(static function (UserBuilder $builder, TextFormField $field) {
+ $builder->setUserTitle(StringUtil::trim((string)$field->getValue()));
+ });
+ }
- // validate user language
- $language = LanguageFactory::getInstance()->getLanguage($this->languageID);
- if ($language === null) {
- // use default language
- $this->languageID = LanguageFactory::getInstance()->getDefaultLanguageID();
+ protected function getGroupsFormField(): ?MultipleSelectionFormField
+ {
+ $availableGroups = UserGroup::getSortedAccessibleGroups(
+ [],
+ [UserGroup::GUESTS, UserGroup::EVERYONE, UserGroup::USERS]
+ );
+ if ($availableGroups === []) {
+ return null;
}
- // validate visible languages
- foreach ($this->visibleLanguages as $key => $visibleLanguage) {
- $language = LanguageFactory::getInstance()->getLanguage($visibleLanguage);
- if ($language === null || $language->hasContent === 0) {
- unset($this->visibleLanguages[$key]);
- }
- }
- if (
- $this->visibleLanguages === []
- && ($language = LanguageFactory::getInstance()->getLanguage($this->languageID)) !== null
- && $language->hasContent !== 0
- ) {
- $this->visibleLanguages[] = $this->languageID;
- }
+ $ownerGroupID = UserGroup::getOwnerGroupID();
+ $options = [];
+ foreach ($availableGroups as $group) {
+ $options[$group->groupID] = StringUtil::encodeHTML($group->getName());
- // validate user title
- try {
- if (\mb_strlen($this->userTitle) > \USER_TITLE_MAX_LENGTH) {
- throw new UserInputException('userTitle', 'tooLong');
+ if ($group->groupID === $ownerGroupID) {
+ $options[$group->groupID] .= ' ' . FontAwesomeIcon::fromValues('shield-halved')->toHtml(16);
}
- if (!StringUtil::executeWordFilter($this->userTitle, \USER_FORBIDDEN_TITLES)) {
- throw new UserInputException('userTitle', 'forbidden');
- }
- } catch (UserInputException $e) {
- $this->errorType[$e->getField()] = $e->getType();
- }
-
- // validate signature
- $this->htmlInputProcessor = new HtmlInputProcessor();
- $this->htmlInputProcessor->process($this->signature, 'com.woltlab.wcf.user.signature', 0);
-
- BBCodeHandler::getInstance()->setDisallowedBBCodes(\explode(
- ',',
- WCF::getSession()->getPermission('user.signature.disallowedBBCodes')
- ));
- $disallowedBBCodes = $this->htmlInputProcessor->validate();
- if ($disallowedBBCodes !== []) {
- WCF::getTPL()->assign('disallowedBBCodes', $disallowedBBCodes);
- throw new UserInputException('signature', 'disallowedBBCodes');
}
- // validate dynamic options
- parent::validate();
- }
-
- #[\Override]
- public function save()
- {
- AbstractForm::save();
-
- // create
- $saveOptions = $this->optionHandler->save();
- $this->additionalFields['languageID'] = $this->languageID;
- $data = [
- 'data' => \array_merge($this->additionalFields, [
- 'username' => $this->username,
- 'email' => $this->email,
- 'password' => $this->password,
- 'userTitle' => $this->userTitle,
- 'signature' => $this->htmlInputProcessor->getHtml(),
- 'signatureEnableHtml' => 1,
- ]),
- 'groups' => $this->groupIDs,
- 'languageIDs' => $this->visibleLanguages,
- 'options' => $saveOptions,
- 'signatureAttachmentHandler' => $this->attachmentHandler,
- ];
-
- $this->objectAction = new UserAction([], 'create', $data);
- $returnValues = $this->objectAction->executeAction();
-
- $this->htmlInputProcessor->setObjectID($returnValues['returnValues']->userID);
- MessageEmbeddedObjectManager::getInstance()->registerObjects($this->htmlInputProcessor);
- $this->attachmentHandler->updateObjectID($returnValues['returnValues']->userID);
-
- $this->saved();
-
- // show empty add form
- WCF::getTPL()->assign([
- 'success' => true,
- 'objectEditLink' => LinkHandler::getInstance()->getControllerLink(
- UserEditForm::class,
- ['id' => $returnValues['returnValues']->userID]
- ),
- ]);
-
- // reset values
- $this->username = $this->email = $this->password = $this->userTitle = '';
- $this->signature = '';
- $this->groupIDs = [];
- $this->languageID = $this->getDefaultFormLanguageID();
- $this->optionHandler->resetOptionValues();
- // Reload attachment handler to reset the uploaded attachments.
- $this->attachmentHandler = new AttachmentHandler(
- $this->attachmentObjectType,
- $this->attachmentObjectID,
- $this->tmpHash
- );
+ return MultipleSelectionFormField::create('groupIDs')
+ ->label('wcf.acp.user.groups')
+ ->options($options, false, false)
+ ->ignoreInvalidValues()
+ ->loadValueCallback(static function (User $user, MultipleSelectionFormField $field) {
+ $field->value($user->getGroupIDs(true));
+ })
+ ->addValidator(new FormFieldValidator('ownerGroup', function (MultipleSelectionFormField $field) use ($ownerGroupID) {
+ if (
+ $ownerGroupID !== null
+ && $this->formObject?->userID === WCF::getUser()->userID
+ && WCF::getUser()->hasOwnerAccess()
+ && !\in_array($ownerGroupID, \array_map(\intval(...), $field->getValue() ?? []), true)
+ ) {
+ // Members of the owner group cannot remove themselves.
+ $field->addValidationError(new FormFieldValidationError(
+ 'ownerGroup',
+ 'wcf.acp.user.groups.error.ownerGroup'
+ ));
+ }
+ }))
+ ->saveValueCallback(function (UserBuilder $builder, MultipleSelectionFormField $field) {
+ $builder->setGroupIDs($this->getGroupIDsWithDefaultGroups(
+ \array_map(\intval(...), $field->getValue() ?? [])
+ ));
+ });
}
/**
- * Throws a UserInputException if the username is not unique or not valid.
+ * Adds the default groups to the selected groups, which cannot be selected
+ * explicitly.
*
- * @return void
- * @throws UserInputException
+ * @param list $groupIDs
+ * @return list
*/
- protected function validateUsername(string $username)
+ protected function getGroupIDsWithDefaultGroups(array $groupIDs): array
{
- if (empty($username)) {
- throw new UserInputException('username');
- }
-
- // check for forbidden chars (e.g. the ",")
- if (!UserUtil::isValidUsername($username)) {
- throw new UserInputException('username', 'invalid');
+ if ($this->formObject === null) {
+ $defaultGroupIDs = UserGroup::getGroupIDsByType([UserGroup::EVERYONE, UserGroup::USERS]);
+ } else {
+ $defaultGroups = UserGroup::getAccessibleGroups([UserGroup::GUESTS, UserGroup::EVERYONE, UserGroup::USERS]);
+ $defaultGroupIDs = \array_filter(
+ $this->formObject->getGroupIDs(true),
+ static fn(int $groupID) => isset($defaultGroups[$groupID])
+ );
}
- // Check if username exists already.
- if (!User::getUserByUsername($username)->isGuest()) {
- throw new UserInputException('username', 'notUnique');
- }
+ return \array_values(\array_unique(\array_merge($groupIDs, $defaultGroupIDs)));
}
/**
- * Throws a UserInputException if the email is not unique or not valid.
- * @throws UserInputException
+ * @param Language[] $availableLanguages
+ * @return IFormField[]
*/
- protected function validateEmail(string $email): void
+ protected function getLanguageFormFields(array $availableLanguages): array
{
- if (empty($email)) {
- throw new UserInputException('email');
- }
+ $fields = [
+ SelectFormField::create('languageID')
+ ->label('wcf.user.language')
+ ->description('wcf.user.language.description')
+ ->options(\array_map(
+ static fn(Language $language) => StringUtil::encodeHTML($language->languageName),
+ $availableLanguages
+ ))
+ ->required()
+ ->ignoreInvalidValues()
+ ->defaultValue(LanguageFactory::getInstance()->getDefaultLanguageID())
+ ->value(LanguageFactory::getInstance()->getDefaultLanguageID())
+ ->saveValueCallback(static function (UserBuilder $builder, SelectFormField $field) {
+ $builder->setLanguageID((int)$field->getSaveValue());
+ }),
+ ];
- // check for valid email (one @ etc.)
- if (!UserUtil::isValidEmail($email)) {
- throw new UserInputException('email', 'invalid');
+ $availableContentLanguages = LanguageFactory::getInstance()->getContentLanguages();
+ if ($availableContentLanguages !== []) {
+ $fields[] = MultipleSelectionFormField::create('visibleLanguages')
+ ->label('wcf.user.visibleLanguages')
+ ->description('wcf.user.visibleLanguages.description')
+ ->options(\array_map(
+ static fn(Language $language) => StringUtil::encodeHTML($language->languageName),
+ $availableContentLanguages
+ ))
+ ->ignoreInvalidValues()
+ ->loadValueCallback(static function (User $user, MultipleSelectionFormField $field) {
+ $field->value($user->getLanguageIDs());
+ })
+ ->saveValueCallback(
+ static function (UserBuilder $builder, MultipleSelectionFormField $field) use ($availableContentLanguages) {
+ $languageIDs = \array_map(\intval(...), $field->getValue() ?? []);
+ if ($languageIDs === []) {
+ $languageID = (int)$field->getDocument()->getFormField('languageID')?->getSaveValue();
+ if (isset($availableContentLanguages[$languageID])) {
+ $languageIDs[] = $languageID;
+ }
+ }
+
+ $builder->setLanguageIDs($languageIDs);
+ }
+ );
}
- // Check if email exists already.
- if (!User::getUserByEmail($email)->isGuest()) {
- throw new UserInputException('email', 'notUnique');
- }
+ return $fields;
}
/**
- * Throws a UserInputException if the password is not valid.
- * @throws UserInputException
+ * Returns the form fields for the style and the color scheme of an existing user.
+ *
+ * @return IFormField[]
*/
- protected function validatePassword(
- #[\SensitiveParameter]
- string $password
- ): void {
- if (empty($password)) {
- throw new UserInputException('password');
- }
- }
-
- #[\Override]
- public function readData()
+ protected function getStyleFormFields(): array
{
- $this->attachmentHandler = new AttachmentHandler(
- $this->attachmentObjectType,
- $this->attachmentObjectID,
- $this->tmpHash,
- 0
+ $fields = [];
+
+ // Disabled styles are only available if the edited user may use them.
+ $userProfile = new UserProfile($this->formObject);
+ $availableStyles = \array_filter(
+ StyleHandler::getInstance()->getStyles(),
+ static fn(Style $style) => $style->isDisabled === 0
+ || $userProfile->hasPermission('admin.style.canUseDisabledStyle')
);
+ if (\count($availableStyles) > 1) {
+ $styles = [0 => 'wcf.global.defaultValue'];
+ foreach ($availableStyles as $style) {
+ $styles[$style->styleID] = StringUtil::encodeHTML($style->styleName);
+ }
+
+ $fields[] = SelectFormField::create('styleID')
+ ->label('wcf.user.style')
+ ->description('wcf.user.style.description')
+ ->options($styles)
+ ->required()
+ ->ignoreInvalidValues()
+ ->defaultValue(0)
+ ->saveValueCallback(static function (UserBuilder $builder, SelectFormField $field) {
+ $builder->setStyleID((int)$field->getSaveValue());
+ });
+ }
+
+ $fields[] = SelectFormField::create('colorScheme')
+ ->label('wcf.user.style.colorScheme')
+ ->description('wcf.user.style.colorScheme.description')
+ ->options([
+ 'system' => 'wcf.style.setColorScheme.system',
+ 'light' => 'wcf.style.setColorScheme.light',
+ 'dark' => 'wcf.style.setColorScheme.dark',
+ ])
+ ->required()
+ ->ignoreInvalidValues()
+ ->defaultValue('system')
+ ->loadValueCallback(static function (User $user, SelectFormField $field) {
+ $field->value($user->getUserOption('colorScheme') ?? 'system');
+ })
+ ->saveValueCallback(static function (UserBuilder $builder, SelectFormField $field) {
+ $optionID = User::getUserOptionID('colorScheme');
+ if ($optionID !== null) {
+ $builder->setUserOption($optionID, (string)$field->getSaveValue());
+ }
+ });
- parent::readData();
- // get default smilies
- if (\MODULE_SMILEY !== 0) {
- $this->smileyCategories = SmileyCache::getInstance()->getVisibleCategories();
+ return $fields;
+ }
- $firstCategory = \reset($this->smileyCategories);
- if ($firstCategory) {
- $this->defaultSmilies = SmileyCache::getInstance()
- ->getCategorySmilies($firstCategory->categoryID ?: null);
+ protected function getSignatureFormContainer(): WysiwygFormContainer
+ {
+ $container = WysiwygFormContainer::create('signature')
+ ->label('wcf.user.signature')
+ ->messageObjectType('com.woltlab.wcf.user.signature')
+ ->attachmentData('com.woltlab.wcf.user.signature', objectID: $this->formObject->userID ?? 0);
+
+ $container->getWysiwygField()
+ ->loadValueCallback(static function (User $user, WysiwygFormField $field) {
+ $field->value($user->signature ?? '');
+ })
+ ->saveValueCallback(static function (UserBuilder $builder, WysiwygFormField $field) {
+ $builder
+ ->setSignatureHtmlInputProcessor($field->getHtmlInputProcessor())
+ ->setSignatureEnableHtml(true);
+ });
+ $container->getAttachmentField()->saveValueCallback(
+ static function (UserBuilder $builder, WysiwygAttachmentFormField $field) {
+ $attachmentHandler = $field->getAttachmentHandler();
+ if ($attachmentHandler !== null) {
+ $builder->setSignatureAttachmentHandler($attachmentHandler);
+ }
}
- }
+ );
- $this->readOptionTree();
+ return $container;
}
/**
- * Reads option tree on page init.
- *
- * @return void
+ * Returns `true` if the active user can edit the given option of the
+ * edited user.
*/
- protected function readOptionTree()
+ protected function isEditableOption(UserOption $option): bool
{
- $this->optionTree = $this->optionHandler->getOptionTree();
- }
+ if (!$option->validateOptions() || !$option->validatePermissions()) {
+ return false;
+ }
- #[\Override]
- public function assignVariables()
- {
- parent::assignVariables();
-
- WCF::getTPL()->assign([
- 'username' => $this->username,
- 'email' => $this->email,
- 'password' => $this->password,
- 'groupIDs' => $this->groupIDs,
- 'optionTree' => $this->optionTree,
- 'availableGroups' => $this->getAvailableGroups(),
- 'availableLanguages' => LanguageFactory::getInstance()->getLanguages(),
- 'languageID' => $this->languageID,
- 'visibleLanguages' => $this->visibleLanguages,
- 'availableContentLanguages' => LanguageFactory::getInstance()->getContentLanguages(),
- 'action' => 'add',
- 'userTitle' => $this->userTitle,
- 'signature' => $this->signature,
- 'attachmentHandler' => $this->attachmentHandler,
- 'attachmentObjectID' => $this->attachmentObjectID,
- 'attachmentObjectType' => $this->attachmentObjectType,
- 'attachmentParentObjectID' => 0,
- 'defaultSmilies' => $this->defaultSmilies,
- 'smileyCategories' => $this->smileyCategories,
- 'tmpHash' => $this->tmpHash,
- ]);
+ if ($this->formObject === null) {
+ return $option->isEditable();
+ }
+
+ // The cached option objects are shared, the editability depends on the user.
+ $option = clone $option;
+ $option->setUser($this->formObject);
+
+ return $option->isEditable();
}
- #[\Override]
- public function show()
+ /**
+ * Returns the form field for the given user option, administrators may
+ * leave required options empty when editing other users.
+ */
+ protected function getUserOptionFormField(UserOption $option): AbstractFormField
{
- // get the default language id
- $this->languageID = $this->getDefaultFormLanguageID();
+ $formField = $this->getDefaultUserOptionFormField($option);
+
+ if ($this->formObject !== null && $this->formObject->userID !== WCF::getUser()->userID) {
+ $formField->required(false);
+ }
- // show form
- parent::show();
+ return $formField;
}
}
diff --git a/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php b/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php
index d7d88b901cc..65e14280a6d 100755
--- a/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php
@@ -2,31 +2,19 @@
namespace wcf\acp\form;
-use wcf\data\style\Style;
-use wcf\data\user\group\UserGroup;
use wcf\data\user\User;
-use wcf\data\user\UserAction;
-use wcf\data\user\UserEditor;
-use wcf\data\user\UserProfileAction;
-use wcf\form\AbstractForm;
-use wcf\system\cache\runtime\UserProfileRuntimeCache;
-use wcf\system\exception\IllegalLinkException;
+use wcf\data\user\UserProfile;
+use wcf\http\Helper;
use wcf\system\exception\PermissionDeniedException;
-use wcf\system\exception\UserInputException;
-use wcf\system\html\upcast\HtmlUpcastProcessor;
-use wcf\system\message\embedded\object\MessageEmbeddedObjectManager;
-use wcf\system\moderation\queue\ModerationQueueManager;
-use wcf\system\style\StyleHandler;
-use wcf\command\user\SetColorScheme;
use wcf\system\view\user\profile\AdminUserProfileHeaderView;
use wcf\system\WCF;
/**
* Shows the user edit form.
*
- * @author Marcel Werk
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License
+ * @author Marcel Werk
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
*/
class UserEditForm extends UserAddForm
{
@@ -41,294 +29,32 @@ class UserEditForm extends UserAddForm
public $neededPermissions = ['admin.user.canEditUser'];
/**
- * user id
- * @var int
- */
- public $userID = 0;
-
- /**
- * user editor object
- * @var ?UserEditor
- */
- public $user;
-
- /**
- * list of available styles for the edited user
- * @var Style[]
- * @since 5.3
- */
- public $availableStyles = [];
-
- /**
- * id of the used style
- * @var int
- * @since 5.3
- */
- public $styleID = 0;
-
- /**
- * @since 6.0
+ * @inheritDoc
*/
- public string $colorScheme = 'system';
+ public string $formAction = 'edit';
#[\Override]
- public function readParameters()
+ public function readParameters(): void
{
- if (isset($_REQUEST['id'])) {
- $this->userID = \intval($_REQUEST['id']);
- }
- $user = new User($this->userID);
- if ($user->isGuest()) {
- throw new IllegalLinkException();
- }
-
- $this->user = new UserEditor($user);
- if (!UserGroup::isAccessibleGroup($this->user->getGroupIDs())) {
+ $this->formObject = Helper::fetchObjectFromQueryParameter(User::class);
+ if (!$this->formObject->canEdit()) {
throw new PermissionDeniedException();
}
- $this->attachmentObjectID = $this->user->userID;
parent::readParameters();
}
#[\Override]
- protected function initOptionHandler()
- {
- $this->optionHandler->setUser($this->user->getDecoratedObject());
- }
-
- #[\Override]
- public function readFormParameters()
- {
- parent::readFormParameters();
-
- if (!WCF::getSession()->hasPermission('admin.user.canEditPassword') || !empty($this->user->authData)) {
- $this->password = '';
- }
- if (!WCF::getSession()->hasPermission('admin.user.canEditMailAddress')) {
- $this->email = $this->user->email;
- }
-
- if (isset($_POST['styleID'])) {
- $this->styleID = \intval($_POST['styleID']);
- }
- if (isset($_POST['colorScheme'])) {
- $this->colorScheme = $_POST['colorScheme'];
- }
- }
-
- #[\Override]
- public function readData()
- {
- if ($_POST === []) {
- // get visible languages
- $this->readVisibleLanguages();
-
- // default values
- $this->readDefaultValues();
- }
-
- $userProfile = UserProfileRuntimeCache::getInstance()->getObject($this->userID);
- foreach (StyleHandler::getInstance()->getStyles() as $style) {
- if ($style->isDisabled === 0 || $userProfile->hasPermission('admin.style.canUseDisabledStyle')) {
- $this->availableStyles[$style->styleID] = $style;
- }
- }
-
- parent::readData();
- }
-
- /**
- * Sets the selected languages.
- *
- * @return void
- */
- protected function readVisibleLanguages()
- {
- $this->visibleLanguages = $this->user->getLanguageIDs();
- }
-
- /**
- * Sets the default values.
- *
- * @return void
- */
- protected function readDefaultValues()
- {
- $this->username = $this->user->username;
- $this->email = $this->user->email;
- $this->groupIDs = $this->user->getGroupIDs(true);
- $this->languageID = $this->user->languageID;
- $this->userTitle = $this->user->userTitle;
- $this->styleID = $this->user->styleID;
-
- $this->signature = $this->user->signature;
-
- $this->colorScheme = $this->user->getUserOption('colorScheme') ?? 'system';
- }
-
- #[\Override]
- public function assignVariables()
+ public function assignVariables(): void
{
parent::assignVariables();
- $signatureProcessor = new HtmlUpcastProcessor();
- $signatureProcessor->process($this->signature ?: '', 'com.woltlab.wcf.user.signature', $this->user->userID);
+ // Use the saved user, the header would show outdated data otherwise.
+ $user = $this->object ?? $this->formObject;
WCF::getTPL()->assign([
- 'userProfileHeaderView' => new AdminUserProfileHeaderView(
- UserProfileRuntimeCache::getInstance()->getObject($this->userID)
- ),
- 'userID' => $this->user->userID,
- 'action' => 'edit',
- 'url' => '',
- 'user' => $this->user,
- 'ownerGroupID' => UserGroup::getOwnerGroupID(),
- 'availableStyles' => $this->availableStyles,
- 'styleID' => $this->styleID,
- 'colorScheme' => $this->colorScheme,
- 'signature' => $signatureProcessor->getHtml(),
+ 'userID' => $user->userID,
+ 'userProfileHeaderView' => new AdminUserProfileHeaderView(new UserProfile($user)),
]);
}
-
- #[\Override]
- public function save()
- {
- AbstractForm::save();
- $this->htmlInputProcessor->setObjectID($this->userID);
- MessageEmbeddedObjectManager::getInstance()->registerObjects($this->htmlInputProcessor);
-
- // add default groups
- $defaultGroups = UserGroup::getAccessibleGroups([UserGroup::GUESTS, UserGroup::EVERYONE, UserGroup::USERS]);
- $oldGroupIDs = $this->user->getGroupIDs(true);
- foreach ($oldGroupIDs as $oldGroupID) {
- if (isset($defaultGroups[$oldGroupID])) {
- $this->groupIDs[] = $oldGroupID;
- }
- }
- $this->groupIDs = \array_unique($this->groupIDs);
-
- // save user
- $saveOptions = $this->optionHandler->save();
-
- $data = [
- 'data' => \array_merge($this->additionalFields, [
- 'username' => $this->username,
- 'email' => $this->email,
- 'password' => $this->password,
- 'languageID' => $this->languageID,
- 'userTitle' => $this->userTitle,
- 'signature' => $this->htmlInputProcessor->getHtml(),
- 'signatureEnableHtml' => 1,
- 'styleID' => $this->styleID,
- ]),
- 'groups' => $this->groupIDs,
- 'languageIDs' => $this->visibleLanguages,
- 'options' => $saveOptions,
- 'signatureAttachmentHandler' => $this->attachmentHandler,
- ];
- // handle changed username
- if (\mb_strtolower($this->username) !== \mb_strtolower($this->user->username)) {
- $data['data']['lastUsernameChange'] = \TIME_NOW;
- $data['data']['oldUsername'] = $this->user->username;
- }
-
- $this->objectAction = new UserAction([$this->userID], 'update', $data);
- $this->objectAction->executeAction();
-
- if ($this->user->getUserOption('colorScheme') !== $this->colorScheme) {
- new SetColorScheme($this->user->getDecoratedObject(), $this->colorScheme)();
- }
-
- // reload user
- $this->user = new UserEditor(new User($this->userID));
-
- // update user rank
- if (\MODULE_USER_RANK !== 0) {
- $action = new UserProfileAction([$this->user], 'updateUserRank');
- $action->executeAction();
- }
- if (\MODULE_USERS_ONLINE !== 0) {
- $action = new UserProfileAction([$this->user], 'updateUserOnlineMarking');
- $action->executeAction();
- }
-
- // remove assignments
- $sql = "DELETE FROM wcf1_moderation_queue_to_user
- WHERE userID = ?";
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute([$this->user->userID]);
-
- // reset moderation count
- ModerationQueueManager::getInstance()->resetModerationCount($this->user->userID);
- $this->saved();
-
- // reset password
- $this->password = '';
-
- // reload user
- $this->user = new UserEditor(new User($this->userID));
-
- // show success message
- WCF::getTPL()->assign('success', true);
- }
-
- #[\Override]
- protected function validateUsername(string $username)
- {
- try {
- if (\mb_strtolower($this->user->username) !== \mb_strtolower($username)) {
- parent::validateUsername($username);
- }
- } catch (UserInputException $e) {
- if ($e->getField() === 'username' && $e->getType() === 'notUnique') {
- $user2 = User::getUserByUsername($username);
- if ($user2->userID !== $this->user->userID) {
- throw $e;
- }
- } else {
- throw $e;
- }
- }
- }
-
- #[\Override]
- protected function validateEmail(string $email): void
- {
- if (\mb_strtolower($this->user->email) !== \mb_strtolower($email)) {
- parent::validateEmail($email);
- }
- }
-
- #[\Override]
- protected function validatePassword(
- #[\SensitiveParameter]
- string $password
- ): void {
- if (!empty($password)) {
- parent::validatePassword($password);
- }
- }
-
- #[\Override]
- public function validate()
- {
- if ($this->user->userID === WCF::getUser()->userID && WCF::getUser()->hasOwnerAccess()) {
- $ownerGroupID = UserGroup::getOwnerGroupID();
- if ($ownerGroupID !== null && !\in_array($ownerGroupID, $this->groupIDs, true)) {
- // Members of the owner group cannot remove themselves.
- throw new PermissionDeniedException();
- }
- }
-
- parent::validate();
-
- if (!isset($this->availableStyles[$this->styleID])) {
- $this->styleID = 0;
- }
-
- if ($this->colorScheme !== 'light' && $this->colorScheme !== 'dark') {
- $this->colorScheme = 'system';
- }
- }
}
diff --git a/wcfsetup/install/files/lib/command/user/UpdateUser.class.php b/wcfsetup/install/files/lib/command/user/UpdateUser.class.php
index df284b6275e..465b26d67d1 100644
--- a/wcfsetup/install/files/lib/command/user/UpdateUser.class.php
+++ b/wcfsetup/install/files/lib/command/user/UpdateUser.class.php
@@ -8,6 +8,7 @@
use wcf\event\user\UserRenamed;
use wcf\event\user\UserUpdated;
use wcf\system\event\EventHandler;
+use wcf\system\moderation\queue\ModerationQueueManager;
use wcf\system\session\SessionHandler;
use wcf\system\user\group\assignment\UserGroupAssignmentHandler;
use wcf\system\user\storage\UserStorageHandler;
@@ -57,6 +58,7 @@ public function __invoke(): User
$groupsChanged = $this->builder->getGroupIDs() !== null;
if ($groupsChanged) {
UserStorageHandler::getInstance()->reset([$user->userID], 'groupIDs');
+ $this->resetModerationQueueAssignments($user);
}
// The automatic group assignments and the user rank can depend on user options.
@@ -79,6 +81,20 @@ public function __invoke(): User
return $user;
}
+ /**
+ * Removes the assignments to the moderation queue, the access to the queue
+ * entries depends on the permissions of the user groups.
+ */
+ private function resetModerationQueueAssignments(User $user): void
+ {
+ $sql = "DELETE FROM wcf1_moderation_queue_to_user
+ WHERE userID = ?";
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute([$user->userID]);
+
+ ModerationQueueManager::getInstance()->resetModerationCount($user->userID);
+ }
+
/**
* Updates the username that is stored alongside the user id in other tables.
*/
diff --git a/wcfsetup/install/files/lib/data/user/UserBuilder.class.php b/wcfsetup/install/files/lib/data/user/UserBuilder.class.php
index 5840258166b..7881dad8dd1 100644
--- a/wcfsetup/install/files/lib/data/user/UserBuilder.class.php
+++ b/wcfsetup/install/files/lib/data/user/UserBuilder.class.php
@@ -5,6 +5,9 @@
use ParagonIE\ConstantTime\Hex;
use wcf\data\DatabaseObject;
use wcf\data\DatabaseObjectBuilder;
+use wcf\system\attachment\AttachmentHandler;
+use wcf\system\html\input\HtmlInputProcessor;
+use wcf\system\message\embedded\object\MessageEmbeddedObjectManager;
use wcf\system\user\authentication\password\algorithm\Invalid as InvalidPasswordAlgorithm;
use wcf\system\user\authentication\password\PasswordAlgorithmManager;
use wcf\system\WCF;
@@ -46,6 +49,10 @@ final class UserBuilder extends DatabaseObjectBuilder
private bool $passwordChanged = false;
+ private HtmlInputProcessor $signatureHtmlInputProcessor;
+
+ private AttachmentHandler $signatureAttachmentHandler;
+
public function setUsername(string $username): static
{
$this->properties['username'] = $username;
@@ -217,6 +224,29 @@ public function setSignatureEnableHtml(bool $signatureEnableHtml): static
return $this;
}
+ /**
+ * Sets the signature from the given processor, the embedded objects are
+ * registered after the user has been saved.
+ */
+ public function setSignatureHtmlInputProcessor(HtmlInputProcessor $htmlInputProcessor): static
+ {
+ $this->signatureHtmlInputProcessor = $htmlInputProcessor;
+ $this->setSignature($htmlInputProcessor->getHtml());
+
+ return $this;
+ }
+
+ /**
+ * Sets the handler of the signature attachments, which are assigned to the
+ * user after it has been created.
+ */
+ public function setSignatureAttachmentHandler(AttachmentHandler $attachmentHandler): static
+ {
+ $this->signatureAttachmentHandler = $attachmentHandler;
+
+ return $this;
+ }
+
public function setDisableSignature(bool $disableSignature): static
{
$this->properties['disableSignature'] = (int)$disableSignature;
@@ -385,6 +415,14 @@ protected function afterCreate(DatabaseObject $object): void
}
$editor->addToLanguages($this->languageIDs ?? [], false);
+ if (isset($this->signatureHtmlInputProcessor)) {
+ $this->registerSignatureEmbeddedObjects($object);
+ }
+
+ if (isset($this->signatureAttachmentHandler)) {
+ $this->signatureAttachmentHandler->updateObjectID($object->userID);
+ }
+
if (\PACKAGE_ID !== 0) {
// set default notifications
$sql = "INSERT INTO wcf1_user_notification_event_to_user
@@ -413,6 +451,16 @@ protected function afterUpdate(DatabaseObject $object): void
if ($this->languageIDs !== null) {
$editor->addToLanguages($this->languageIDs, true);
}
+
+ if (isset($this->signatureHtmlInputProcessor)) {
+ $this->registerSignatureEmbeddedObjects($object);
+ }
+ }
+
+ private function registerSignatureEmbeddedObjects(User $user): void
+ {
+ $this->signatureHtmlInputProcessor->setObjectID($user->userID);
+ MessageEmbeddedObjectManager::getInstance()->registerObjects($this->signatureHtmlInputProcessor);
}
/**
diff --git a/wcfsetup/install/files/lib/system/form/builder/field/user/UsernameFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/user/UsernameFormField.class.php
index 73ce542c99f..6cc67fabb30 100644
--- a/wcfsetup/install/files/lib/system/form/builder/field/user/UsernameFormField.class.php
+++ b/wcfsetup/install/files/lib/system/form/builder/field/user/UsernameFormField.class.php
@@ -66,6 +66,11 @@ final class UsernameFormField extends AbstractFormField implements
*/
protected $templateName = 'shared_usernameFormField';
+ /**
+ * user whose current username is accepted
+ */
+ private ?User $exceptedUser = null;
+
public function __construct()
{
$this->label('wcf.user.username');
@@ -73,6 +78,29 @@ public function __construct()
$this->minimumLength(3);
}
+ /**
+ * Accepts the current username of the given user, which is required when
+ * editing an existing user.
+ *
+ * @since 6.3
+ */
+ public function exceptUser(?User $user): static
+ {
+ $this->exceptedUser = $user;
+
+ return $this;
+ }
+
+ /**
+ * Returns the user whose current username is accepted.
+ *
+ * @since 6.3
+ */
+ public function getExceptedUser(): ?User
+ {
+ return $this->exceptedUser;
+ }
+
/**
* @return string[]
* @since 5.4
@@ -113,6 +141,11 @@ public function validate()
if ($this->isRequired()) {
$this->addValidationError(new FormFieldValidationError('empty'));
}
+ } elseif (
+ $this->exceptedUser !== null
+ && \mb_strtolower($this->getValue()) === \mb_strtolower($this->exceptedUser->username)
+ ) {
+ // The current username remains valid, even if the rules have changed since.
} else {
$this->validateMinimumLength($this->getValue());
$this->validateMaximumLength($this->getValue());
diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml
index b156b24cc10..a585fb45358 100644
--- a/wcfsetup/install/lang/de.xml
+++ b/wcfsetup/install/lang/de.xml
@@ -3066,6 +3066,7 @@ Abschnitte dürfen nicht leer sein und nur folgende Zeichen enthalten: [a-z
+
@@ -3160,7 +3161,6 @@ Abschnitte dürfen nicht leer sein und nur folgende Zeichen enthalten: [a-z
-
@@ -4744,7 +4744,6 @@ sich{/if} nicht bei uns registriert {if LANGUAGE_USE_INFORMAL_VARIANT}hast{else}
-
@@ -5817,5 +5816,7 @@ Erlaubte Dateiendungen: {', '|implode:$allowedFileExtensions}]]>
+
+
diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml
index 46ff4d100c3..e24f1d86aea 100644
--- a/wcfsetup/install/lang/en.xml
+++ b/wcfsetup/install/lang/en.xml
@@ -2995,6 +2995,7 @@ If you have already bought the licenses for the listed apps , th
+
@@ -3089,7 +3090,6 @@ If you have already bought the licenses for the listed apps , th
-
@@ -4740,7 +4740,6 @@ not register with us.]]>
-
@@ -5819,5 +5818,7 @@ Allowed extensions: {', '|implode:$allowedFileExtensions}]]>
+
+
From bf07a012d3830eabb10ea4f4c2a17f898b486c7a Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Wed, 7 Oct 2026 11:35:41 +0200
Subject: [PATCH 048/115] Pass user to `wcf.user.banned` phrase in profile
header
---
com.woltlab.wcf/templates/userProfileHeader.tpl | 2 +-
wcfsetup/install/files/acp/templates/userProfileHeader.tpl | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/com.woltlab.wcf/templates/userProfileHeader.tpl b/com.woltlab.wcf/templates/userProfileHeader.tpl
index 2d3ecd048ec..6a53e393e18 100644
--- a/com.woltlab.wcf/templates/userProfileHeader.tpl
+++ b/com.woltlab.wcf/templates/userProfileHeader.tpl
@@ -55,7 +55,7 @@
+
+
diff --git a/ts/WoltLabSuite/Core/Acp/Component/User/ProfileHeader.ts b/ts/WoltLabSuite/Core/Acp/Component/User/ProfileHeader.ts
new file mode 100644
index 00000000000..892331f14d2
--- /dev/null
+++ b/ts/WoltLabSuite/Core/Acp/Component/User/ProfileHeader.ts
@@ -0,0 +1,47 @@
+/**
+ * Refreshes the user profile header on the user edit page after an
+ * interaction has changed the state of the user.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.3
+ */
+
+import { getAdminUserProfileHeader } from "WoltLabSuite/Core/Api/Users/GetAdminUserProfileHeader";
+
+type Payload = Record;
+
+async function refreshHeader(header: HTMLElement, userId: number): Promise {
+ const { template } = await getAdminUserProfileHeader(userId);
+
+ const container = document.createElement("template");
+ container.innerHTML = template;
+
+ const newHeader = container.content.querySelector(".userProfileHeader");
+ if (!newHeader) {
+ return;
+ }
+
+ // The buttons contain the interaction context menu, which refreshes itself.
+ // Keeping them preserves the initialized dropdown and its event listeners.
+ const buttons = header.querySelector(".userProfileHeader__buttons");
+ const newButtons = newHeader.querySelector(".userProfileHeader__buttons");
+ if (buttons && newButtons) {
+ newButtons.replaceWith(buttons);
+ }
+
+ header.replaceChildren(...newHeader.childNodes);
+}
+
+export function setup(header: HTMLElement): void {
+ const userId = parseInt(header.dataset.objectId!);
+
+ header.addEventListener("interaction:invalidate", (event: CustomEvent) => {
+ if (event.detail._reloadPage === "true") {
+ return;
+ }
+
+ void refreshHeader(header, userId);
+ });
+}
diff --git a/ts/WoltLabSuite/Core/Api/Users/GetAdminUserProfileHeader.ts b/ts/WoltLabSuite/Core/Api/Users/GetAdminUserProfileHeader.ts
new file mode 100644
index 00000000000..9f929a2ea2f
--- /dev/null
+++ b/ts/WoltLabSuite/Core/Api/Users/GetAdminUserProfileHeader.ts
@@ -0,0 +1,23 @@
+/**
+ * Gets the html code for the rendering of the user profile header in the admin panel.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.3
+ */
+
+import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend";
+import { fromInfallibleApiRequest } from "WoltLabSuite/Core/Api/Result";
+
+type Response = {
+ template: string;
+};
+
+export async function getAdminUserProfileHeader(userId: number): Promise {
+ const url = new URL(`${window.WSC_RPC_API_URL}core/users/${userId}/admin-profile-header`);
+
+ return fromInfallibleApiRequest(() => {
+ return prepareRequest(url).get().fetchAsJson();
+ });
+}
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Component/User/ProfileHeader.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Component/User/ProfileHeader.js
new file mode 100644
index 00000000000..526df0678e0
--- /dev/null
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Component/User/ProfileHeader.js
@@ -0,0 +1,40 @@
+/**
+ * Refreshes the user profile header on the user edit page after an
+ * interaction has changed the state of the user.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.3
+ */
+define(["require", "exports", "WoltLabSuite/Core/Api/Users/GetAdminUserProfileHeader"], function (require, exports, GetAdminUserProfileHeader_1) {
+ "use strict";
+ Object.defineProperty(exports, "__esModule", { value: true });
+ exports.setup = setup;
+ async function refreshHeader(header, userId) {
+ const { template } = await (0, GetAdminUserProfileHeader_1.getAdminUserProfileHeader)(userId);
+ const container = document.createElement("template");
+ container.innerHTML = template;
+ const newHeader = container.content.querySelector(".userProfileHeader");
+ if (!newHeader) {
+ return;
+ }
+ // The buttons contain the interaction context menu, which refreshes itself.
+ // Keeping them preserves the initialized dropdown and its event listeners.
+ const buttons = header.querySelector(".userProfileHeader__buttons");
+ const newButtons = newHeader.querySelector(".userProfileHeader__buttons");
+ if (buttons && newButtons) {
+ newButtons.replaceWith(buttons);
+ }
+ header.replaceChildren(...newHeader.childNodes);
+ }
+ function setup(header) {
+ const userId = parseInt(header.dataset.objectId);
+ header.addEventListener("interaction:invalidate", (event) => {
+ if (event.detail._reloadPage === "true") {
+ return;
+ }
+ void refreshHeader(header, userId);
+ });
+ }
+});
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Users/GetAdminUserProfileHeader.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Users/GetAdminUserProfileHeader.js
new file mode 100644
index 00000000000..af6b163ec6c
--- /dev/null
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Users/GetAdminUserProfileHeader.js
@@ -0,0 +1,19 @@
+/**
+ * Gets the html code for the rendering of the user profile header in the admin panel.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.3
+ */
+define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Core/Api/Result"], function (require, exports, Backend_1, Result_1) {
+ "use strict";
+ Object.defineProperty(exports, "__esModule", { value: true });
+ exports.getAdminUserProfileHeader = getAdminUserProfileHeader;
+ async function getAdminUserProfileHeader(userId) {
+ const url = new URL(`${window.WSC_RPC_API_URL}core/users/${userId}/admin-profile-header`);
+ return (0, Result_1.fromInfallibleApiRequest)(() => {
+ return (0, Backend_1.prepareRequest)(url).get().fetchAsJson();
+ });
+ }
+});
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/GetAdminUserProfileHeader.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/GetAdminUserProfileHeader.class.php
new file mode 100644
index 00000000000..8b14aa75e14
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/GetAdminUserProfileHeader.class.php
@@ -0,0 +1,46 @@
+
+ * @since 6.3
+ */
+#[GetRequest('/core/users/{id:\d+}/admin-profile-header')]
+final class GetAdminUserProfileHeader implements IController
+{
+ #[\Override]
+ public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface
+ {
+ if (!WCF::getSession()->hasPermission('admin.user.canEditUser')) {
+ throw new PermissionDeniedException();
+ }
+
+ $user = Helper::fetchObjectFromRequestParameter($variables['id'], User::class);
+
+ if (!$user->canEdit()) {
+ throw new PermissionDeniedException();
+ }
+
+ return new JsonResponse([
+ 'template' => (string)new AdminUserProfileHeaderView(new UserProfile($user)),
+ ]);
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/interaction/admin/UserInteractions.class.php b/wcfsetup/install/files/lib/system/interaction/admin/UserInteractions.class.php
index 4682a1803d5..29024c8121c 100644
--- a/wcfsetup/install/files/lib/system/interaction/admin/UserInteractions.class.php
+++ b/wcfsetup/install/files/lib/system/interaction/admin/UserInteractions.class.php
@@ -26,9 +26,9 @@
/**
* Interaction provider for the user administration.
*
- * The changes of the state of a user reload the page, which only reloads the
- * affected row of the user list, but keeps the form of the user edit page up
- * to date.
+ * Changes of the state of a user reload the affected row of the user list or
+ * the profile header of the user edit page. Disconnecting a third party login
+ * reloads the page, because it affects the form of the user edit page.
*
* @author Marcel Werk
* @copyright 2001-2026 WoltLab GmbH
@@ -47,103 +47,90 @@ public function __construct()
'core/users/%s/enable',
'wcf.acp.user.enable',
isAvailableCallback: static fn(User $user) => $user->pendingActivation()
- && $user->canEnable(),
- interactionEffect: InteractionEffect::ReloadPage
+ && $user->canEnable()
),
new RpcInteraction(
'disable',
'core/users/%s/disable',
'wcf.acp.user.disable',
isAvailableCallback: static fn(User $user) => !$user->pendingActivation()
- && $user->canEnable(),
- interactionEffect: InteractionEffect::ReloadPage
+ && $user->canEnable()
),
new RpcInteraction(
'confirm-email',
'core/users/%s/confirm-email',
'wcf.acp.user.action.confirmEmail',
isAvailableCallback: static fn(User $user) => !$user->isEmailConfirmed()
- && $user->canEnable(),
- interactionEffect: InteractionEffect::ReloadPage
+ && $user->canEnable()
),
new RpcInteraction(
'unconfirm-email',
'core/users/%s/unconfirm-email',
'wcf.acp.user.action.unconfirmEmail',
isAvailableCallback: static fn(User $user) => $user->isEmailConfirmed()
- && $user->canEnable(),
- interactionEffect: InteractionEffect::ReloadPage
+ && $user->canEnable()
),
new RpcInteraction(
'resend-activation-mail',
'core/users/%s/resend-activation-mail',
'wcf.acp.user.action.resendActivationMail',
- isAvailableCallback: static fn(User $user) => $user->canResendActivationMail(),
- interactionEffect: InteractionEffect::ReloadPage
+ isAvailableCallback: static fn(User $user) => $user->canResendActivationMail()
),
new FormBuilderDialogInteraction(
'ban',
LinkHandler::getInstance()->getControllerLink(UserBanAction::class, ['id' => '%s']),
'wcf.acp.user.ban',
static fn(User $user) => $user->banned === 0
- && $user->canBan(),
- InteractionEffect::ReloadPage
+ && $user->canBan()
),
new RpcInteraction(
'unban',
'core/users/%s/unban',
'wcf.acp.user.unban',
isAvailableCallback: static fn(User $user) => $user->banned === 1
- && $user->canBan(),
- interactionEffect: InteractionEffect::ReloadPage
+ && $user->canBan()
),
new FormBuilderDialogInteraction(
'disable-avatar',
LinkHandler::getInstance()->getControllerLink(UserDisableAvatarAction::class, ['id' => '%s']),
'wcf.user.disableAvatar',
static fn(User $user) => $user->disableAvatar === 0
- && $user->canDisableAvatar(),
- InteractionEffect::ReloadPage
+ && $user->canDisableAvatar()
),
new RpcInteraction(
'enable-avatar',
'core/users/%s/enable-avatar',
'wcf.user.enableAvatar',
isAvailableCallback: static fn(User $user) => $user->disableAvatar === 1
- && $user->canDisableAvatar(),
- interactionEffect: InteractionEffect::ReloadPage
+ && $user->canDisableAvatar()
),
new FormBuilderDialogInteraction(
'disable-cover-photo',
LinkHandler::getInstance()->getControllerLink(UserDisableCoverPhotoAction::class, ['id' => '%s']),
'wcf.user.disableCoverPhoto',
static fn(User $user) => $user->disableCoverPhoto === 0
- && $user->canDisableCoverPhoto(),
- InteractionEffect::ReloadPage
+ && $user->canDisableCoverPhoto()
),
new RpcInteraction(
'enable-cover-photo',
'core/users/%s/enable-cover-photo',
'wcf.user.enableCoverPhoto',
isAvailableCallback: static fn(User $user) => $user->disableCoverPhoto === 1
- && $user->canDisableCoverPhoto(),
- interactionEffect: InteractionEffect::ReloadPage
+ && $user->canDisableCoverPhoto()
),
new FormBuilderDialogInteraction(
'disable-signature',
LinkHandler::getInstance()->getControllerLink(UserDisableSignatureAction::class, ['id' => '%s']),
'wcf.user.disableSignature',
static fn(User $user) => $user->disableSignature === 0
- && $user->canDisableSignature(),
- InteractionEffect::ReloadPage
+ && $user->canDisableSignature()
),
new RpcInteraction(
'enable-signature',
'core/users/%s/enable-signature',
'wcf.user.enableSignature',
isAvailableCallback: static fn(User $user) => $user->disableSignature === 1
- && $user->canDisableSignature(),
- interactionEffect: InteractionEffect::ReloadPage
+ && $user->canDisableSignature()
),
new LinkInteraction(
'send-mail',
@@ -180,8 +167,7 @@ public function __construct()
'wcf.acp.user.action.disableMultifactor.confirmMessage',
['user' => $user]
),
- static fn(User $user) => $user->canDisableMultifactor(),
- InteractionEffect::ReloadPage
+ static fn(User $user) => $user->canDisableMultifactor()
),
new LinkInteraction(
'export-gdpr',
diff --git a/wcfsetup/install/files/lib/system/view/user/profile/AdminUserProfileHeaderView.class.php b/wcfsetup/install/files/lib/system/view/user/profile/AdminUserProfileHeaderView.class.php
index 8e1268a285f..6d3c1bcd068 100644
--- a/wcfsetup/install/files/lib/system/view/user/profile/AdminUserProfileHeaderView.class.php
+++ b/wcfsetup/install/files/lib/system/view/user/profile/AdminUserProfileHeaderView.class.php
@@ -51,7 +51,7 @@ public function __construct(
#[\Override]
public function __toString(): string
{
- return WCF::getTPL()->render('wcf', 'userProfileHeader', [
+ return WCF::getTPL()->render('wcf', 'shared_adminUserProfileHeader', [
'view' => $this,
]);
}
From a64a4cb03de96829f8f2ba21e2998f5099659e58 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Sun, 11 Oct 2026 21:26:10 +0200
Subject: [PATCH 082/115] Pass users to content removal dialog description
---
.../lib/acp/action/UserContentRemoveAction.class.php | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
diff --git a/wcfsetup/install/files/lib/acp/action/UserContentRemoveAction.class.php b/wcfsetup/install/files/lib/acp/action/UserContentRemoveAction.class.php
index 8496d40c543..6b393cd6e78 100644
--- a/wcfsetup/install/files/lib/acp/action/UserContentRemoveAction.class.php
+++ b/wcfsetup/install/files/lib/acp/action/UserContentRemoveAction.class.php
@@ -36,7 +36,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface
{
$users = $this->getUsers($request);
- $form = $this->getForm();
+ $form = $this->getForm($users);
if ($request->getMethod() === 'GET') {
return $form->toResponse();
@@ -98,7 +98,10 @@ private function getUsers(ServerRequestInterface $request): array
return $users;
}
- private function getForm(): Psr15DialogForm
+ /**
+ * @param array $users
+ */
+ private function getForm(array $users): Psr15DialogForm
{
$contentProviders = \array_filter(
ObjectTypeCache::getInstance()->getObjectTypes('com.woltlab.wcf.content.userContentProvider'),
@@ -122,7 +125,10 @@ private function getForm(): Psr15DialogForm
->languageItem('wcf.acp.content.provider.removeContentWarning'),
MultipleSelectionFormField::create('contentProviders')
->label('wcf.acp.content.provider.removeContentSectionTitle')
- ->description('wcf.acp.content.provider.removeContentInfo')
+ ->description('wcf.acp.content.provider.removeContentInfo', [
+ 'users' => $users,
+ 'user' => \reset($users),
+ ])
->options($options, false, false)
->value(\array_keys($options))
->required(),
From 0e8312fba6ba92698d50f27733c9ff386132ad33 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Sun, 11 Oct 2026 21:31:24 +0200
Subject: [PATCH 083/115] Inline UserContentRemoveBulkInteraction as anonymous
class
---
.../bulk/admin/UserBulkInteractions.class.php | 24 +++++++++-
...UserContentRemoveBulkInteraction.class.php | 44 -------------------
2 files changed, 23 insertions(+), 45 deletions(-)
delete mode 100644 wcfsetup/install/files/lib/system/interaction/bulk/admin/UserContentRemoveBulkInteraction.class.php
diff --git a/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserBulkInteractions.class.php b/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserBulkInteractions.class.php
index aca40168b82..b43ae67206b 100644
--- a/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserBulkInteractions.class.php
+++ b/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserBulkInteractions.class.php
@@ -2,6 +2,7 @@
namespace wcf\system\interaction\bulk\admin;
+use wcf\acp\action\UserContentRemoveAction;
use wcf\acp\form\UserAssignToGroupForm;
use wcf\acp\form\UserContentRevertChangesForm;
use wcf\acp\form\UserEmailAddressExportForm;
@@ -19,6 +20,7 @@
use wcf\system\interaction\bulk\BulkRpcInteraction;
use wcf\system\interaction\InteractionConfirmationType;
use wcf\system\WCF;
+use wcf\util\StringUtil;
/**
* Bulk interaction provider for the user administration.
@@ -117,7 +119,27 @@ public function __construct()
'core/users/%s',
static fn(User $user) => $user->canDelete()
),
- new UserContentRemoveBulkInteraction(),
+ new class(
+ 'remove-content',
+ UserContentRemoveAction::class,
+ 'wcf.acp.content.removeContent',
+ static fn(User $user) => $user->canDelete()
+ ) extends BulkFormBuilderDialogInteraction {
+ #[\Override]
+ public function renderInitialization(string $containerId): string
+ {
+ $identifier = StringUtil::encodeJS($this->getIdentifier());
+ $containerId = StringUtil::encodeJS($containerId);
+
+ return <<
+ require(['WoltLabSuite/Core/Acp/Component/User/RemoveContent'], ({ setupBulk }) => {
+ setupBulk('{$identifier}', document.getElementById('{$containerId}'));
+ });
+
+ HTML;
+ }
+ },
]);
EventHandler::getInstance()->fire(
diff --git a/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserContentRemoveBulkInteraction.class.php b/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserContentRemoveBulkInteraction.class.php
deleted file mode 100644
index d1899973439..00000000000
--- a/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserContentRemoveBulkInteraction.class.php
+++ /dev/null
@@ -1,44 +0,0 @@
-
- * @since 6.3
- */
-final class UserContentRemoveBulkInteraction extends BulkFormBuilderDialogInteraction
-{
- public function __construct()
- {
- parent::__construct(
- 'remove-content',
- UserContentRemoveAction::class,
- 'wcf.acp.content.removeContent',
- static fn(User $user) => $user->canDelete()
- );
- }
-
- #[\Override]
- public function renderInitialization(string $containerId): string
- {
- $identifier = StringUtil::encodeJS($this->getIdentifier());
- $containerId = StringUtil::encodeJS($containerId);
-
- return <<
- require(['WoltLabSuite/Core/Acp/Component/User/RemoveContent'], ({ setupBulk }) => {
- setupBulk('{$identifier}', document.getElementById('{$containerId}'));
- });
-
- HTML;
- }
-}
From 509707ff4c9f5442ac3871e61356727b8e892d0b Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Sun, 11 Oct 2026 21:33:28 +0200
Subject: [PATCH 084/115] Honor skipNotification in UserAction::confirmEmail()
to avoid admin activation mail on self-activation
---
wcfsetup/install/files/lib/data/user/UserAction.class.php | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/wcfsetup/install/files/lib/data/user/UserAction.class.php b/wcfsetup/install/files/lib/data/user/UserAction.class.php
index 42ebcb6788c..ec74a54ff51 100644
--- a/wcfsetup/install/files/lib/data/user/UserAction.class.php
+++ b/wcfsetup/install/files/lib/data/user/UserAction.class.php
@@ -683,8 +683,9 @@ public function confirmEmail()
$this->readObjects();
}
+ $sendNotification = !(bool)($this->parameters['skipNotification'] ?? false);
foreach ($this->getObjects() as $userEditor) {
- (new ConfirmUserEmail($userEditor->getDecoratedObject()))();
+ (new ConfirmUserEmail($userEditor->getDecoratedObject(), $sendNotification))();
}
}
From 6abe82334613da1fed60c1f22eb437dae02d463e Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Sun, 11 Oct 2026 21:46:01 +0200
Subject: [PATCH 085/115] Migrate user merge to form builder dialog
---
com.woltlab.wcf/acpTemplateDelete.xml | 1 +
.../install/files/acp/templates/userMerge.tpl | 66 --------
.../lib/acp/action/UserMergeAction.class.php | 154 ++++++++++++++++++
.../lib/acp/form/UserMergeForm.class.php | 128 ++++-----------
.../files/lib/bootstrap/com.woltlab.wcf.php | 5 +
.../AbstractUserMergeListener.class.php | 1 +
.../bulk/admin/UserBulkInteractions.class.php | 6 +-
wcfsetup/install/lang/de.xml | 2 +-
wcfsetup/install/lang/en.xml | 2 +-
9 files changed, 199 insertions(+), 166 deletions(-)
delete mode 100644 wcfsetup/install/files/acp/templates/userMerge.tpl
create mode 100644 wcfsetup/install/files/lib/acp/action/UserMergeAction.class.php
diff --git a/com.woltlab.wcf/acpTemplateDelete.xml b/com.woltlab.wcf/acpTemplateDelete.xml
index d54ca8d0361..b9ef980a11e 100644
--- a/com.woltlab.wcf/acpTemplateDelete.xml
+++ b/com.woltlab.wcf/acpTemplateDelete.xml
@@ -159,5 +159,6 @@
contactSettings
removeUserContentDialog
userSearch
+ userMerge
diff --git a/wcfsetup/install/files/acp/templates/userMerge.tpl b/wcfsetup/install/files/acp/templates/userMerge.tpl
deleted file mode 100644
index c5de161a2b7..00000000000
--- a/wcfsetup/install/files/acp/templates/userMerge.tpl
+++ /dev/null
@@ -1,66 +0,0 @@
-{include file='header' pageTitle='wcf.acp.user.merge'}
-
-
-
-{include file='shared_formError'}
-
-{lang}wcf.acp.user.merge.warning{/lang}
-
-
-
-{include file='footer'}
diff --git a/wcfsetup/install/files/lib/acp/action/UserMergeAction.class.php b/wcfsetup/install/files/lib/acp/action/UserMergeAction.class.php
new file mode 100644
index 00000000000..0975f03aba5
--- /dev/null
+++ b/wcfsetup/install/files/lib/acp/action/UserMergeAction.class.php
@@ -0,0 +1,154 @@
+
+ * @since 6.3
+ */
+final class UserMergeAction implements RequestHandlerInterface
+{
+ #[\Override]
+ public function handle(ServerRequestInterface $request): ResponseInterface
+ {
+ $users = $this->getUsers($request);
+ $destinationUsers = $this->getDestinationUsers($users);
+ if ($destinationUsers === []) {
+ throw new PermissionDeniedException();
+ }
+
+ $form = $this->getForm($destinationUsers);
+
+ if ($request->getMethod() === 'GET') {
+ return $form->toResponse();
+ } elseif ($request->getMethod() === 'POST') {
+ $response = $form->validateRequest($request);
+ if ($response !== null) {
+ return $response;
+ }
+
+ $destinationUserID = (int)$form->getData()['data']['destinationUserID'];
+ $mergedUsers = \array_values(\array_filter(
+ $users,
+ static fn(User $user) => $user->userID !== $destinationUserID
+ ));
+
+ (new MergeUsers($users[$destinationUserID], $mergedUsers))();
+
+ return new JsonResponse([]);
+ } else {
+ throw new \LogicException('Unreachable');
+ }
+ }
+
+ /**
+ * @return array
+ */
+ private function getUsers(ServerRequestInterface $request): array
+ {
+ try {
+ $parameters = Helper::mapQueryParameters(
+ $request->getQueryParams(),
+ <<<'EOT'
+ array {
+ ids: positive-int[]
+ }
+ EOT
+ );
+ } catch (MappingError) {
+ throw new IllegalLinkException();
+ }
+
+ $userIDs = \array_unique($parameters['ids']);
+ if (\count($userIDs) < 2) {
+ throw new IllegalLinkException();
+ }
+
+ $userList = new UserList();
+ $userList->setObjectIDs($userIDs);
+ $userList->readObjects();
+ $users = $userList->getObjects();
+ if (\count($users) !== \count($userIDs)) {
+ throw new IllegalLinkException();
+ }
+
+ foreach ($users as $user) {
+ if (!$user->canEdit()) {
+ throw new PermissionDeniedException();
+ }
+ }
+
+ return $users;
+ }
+
+ /**
+ * Returns the users that can be chosen as the destination user. Merged
+ * users are deleted, so every user except the destination user must be
+ * deletable by the active user.
+ *
+ * @param array $users
+ * @return array
+ */
+ private function getDestinationUsers(array $users): array
+ {
+ $undeletableUsers = \array_filter(
+ $users,
+ static fn(User $user) => !$user->canDelete()
+ );
+
+ return match (\count($undeletableUsers)) {
+ 0 => $users,
+ 1 => $undeletableUsers,
+ default => [],
+ };
+ }
+
+ /**
+ * @param array $destinationUsers
+ */
+ private function getForm(array $destinationUsers): Psr15DialogForm
+ {
+ $form = new Psr15DialogForm(
+ static::class,
+ WCF::getLanguage()->get('wcf.acp.user.merge')
+ );
+ $form->appendChildren([
+ NoticeFormNode::create('warning')
+ ->type(NoticeFormNodeType::Warning)
+ ->languageItem('wcf.acp.user.merge.warning'),
+ RadioButtonFormField::create('destinationUserID')
+ ->label('wcf.acp.user.merge.destination')
+ ->description('wcf.acp.user.merge.destination.description')
+ ->options(\array_map(
+ static fn(User $user) => StringUtil::encodeHTML($user->username),
+ $destinationUsers
+ ))
+ ->required(),
+ ]);
+ $form->build();
+
+ return $form;
+ }
+}
diff --git a/wcfsetup/install/files/lib/acp/form/UserMergeForm.class.php b/wcfsetup/install/files/lib/acp/form/UserMergeForm.class.php
index f6cc9550e83..b580b9eee3c 100644
--- a/wcfsetup/install/files/lib/acp/form/UserMergeForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/UserMergeForm.class.php
@@ -2,129 +2,67 @@
namespace wcf\acp\form;
-use wcf\command\user\MergeUsers;
use wcf\data\user\User;
-use wcf\form\AbstractForm;
-use wcf\system\exception\IllegalLinkException;
-use wcf\system\exception\PermissionDeniedException;
-use wcf\system\exception\UserInputException;
-use wcf\system\WCF;
+use wcf\event\user\UsersMerging;
+use wcf\system\event\EventHandler;
/**
- * Shows the user merge form.
+ * Provides the legacy `save` event of the former user merge form for event
+ * listeners that are registered for this class.
+ *
+ * The constructor is private to prevent this class from being resolved as a
+ * controller.
*
* @author Marcel Werk
- * @copyright 2001-2019 WoltLab GmbH
+ * @copyright 2001-2026 WoltLab GmbH
* @license GNU Lesser General Public License
+ * @deprecated 6.3 Listen for the `UsersMerging` event instead.
*/
-class UserMergeForm extends AbstractForm
+final class UserMergeForm
{
- use TMultipleUsersForm;
-
- /**
- * @inheritDoc
- */
- public $neededPermissions = ['admin.user.canEditUser'];
-
- /**
- * @inheritDoc
- */
- public $activeMenuItem = 'wcf.acp.menu.link.user.list';
-
/**
* ids of the relevant users
* @var int[]
*/
- public $userIDs = [];
+ public array $userIDs;
/**
- * relevant users
- * @var User[]
+ * ids of merge users (without destination user)
+ * @var int[]
*/
- public $users = [];
+ public array $mergedUserIDs;
/**
* destination user id
- * @var int
*/
- public $destinationUserID = 0;
+ public int $destinationUserID;
/**
- * ids of merge users (without destination user)
- * @var int[]
+ * relevant users
+ * @var array
*/
- public $mergedUserIDs = [];
+ public array $users = [];
- #[\Override]
- public function readParameters()
+ private function __construct(UsersMerging $event)
{
- parent::readParameters();
-
- $this->users = $this->readUsersFromRequest();
- if (\count($this->users) < 2) {
- throw new IllegalLinkException();
+ $this->destinationUserID = $event->destinationUser->userID;
+ $this->users[$this->destinationUserID] = $event->destinationUser;
+ foreach ($event->mergedUsers as $user) {
+ $this->users[$user->userID] = $user;
}
- foreach ($this->users as $user) {
- if (!$user->canEdit()) {
- throw new PermissionDeniedException();
- }
- }
- $this->userIDs = \array_keys($this->users);
- }
-
- #[\Override]
- public function readFormParameters()
- {
- parent::readFormParameters();
- if (isset($_POST['destinationUserID'])) {
- $this->destinationUserID = \intval($_POST['destinationUserID']);
- }
- }
-
- #[\Override]
- public function validate()
- {
- parent::validate();
-
- if (!isset($this->users[$this->destinationUserID])) {
- throw new UserInputException('destinationUserID');
- }
- }
-
- #[\Override]
- public function save()
- {
- $mergedUsers = [];
- foreach ($this->users as $userID => $user) {
- if ($userID !== $this->destinationUserID) {
- $mergedUsers[] = $user;
- $this->mergedUserIDs[] = $userID;
- }
- }
-
- parent::save();
-
- new MergeUsers($this->users[$this->destinationUserID], $mergedUsers)();
-
- $this->saved();
-
- // show success message
- WCF::getTPL()->assign('message', 'wcf.global.success');
- WCF::getTPL()->display('success');
-
- exit;
+ $this->userIDs = \array_keys($this->users);
+ $this->mergedUserIDs = \array_values(\array_map(
+ static fn(User $user) => $user->userID,
+ $event->mergedUsers
+ ));
}
- #[\Override]
- public function assignVariables()
+ /**
+ * Fires the legacy `save` event.
+ */
+ public static function fireLegacyEvent(UsersMerging $event): void
{
- parent::assignVariables();
-
- WCF::getTPL()->assign([
- 'users' => $this->users,
- 'userIDs' => $this->userIDs,
- 'destinationUserID' => $this->destinationUserID,
- ]);
+ EventHandler::getInstance()->fireAction(new self($event), 'save');
}
}
diff --git a/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php b/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php
index d9736269631..5a0094389d3 100644
--- a/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php
+++ b/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php
@@ -48,6 +48,11 @@ static function (\wcf\event\session\PreserveVariablesCollecting $event) {
\wcf\system\event\listener\UsernameValidatingCheckCharactersListener::class
);
+ $eventHandler->register(
+ \wcf\event\user\UsersMerging::class,
+ \wcf\acp\form\UserMergeForm::fireLegacyEvent(...)
+ );
+
$eventHandler->register(
\wcf\event\user\RegistrationSpamChecking::class,
\wcf\system\event\listener\RegistrationSpamCheckingSfsListener::class
diff --git a/wcfsetup/install/files/lib/system/event/listener/AbstractUserMergeListener.class.php b/wcfsetup/install/files/lib/system/event/listener/AbstractUserMergeListener.class.php
index e31b522c3d2..52bc30f4a04 100644
--- a/wcfsetup/install/files/lib/system/event/listener/AbstractUserMergeListener.class.php
+++ b/wcfsetup/install/files/lib/system/event/listener/AbstractUserMergeListener.class.php
@@ -13,6 +13,7 @@
* @author Matthias Schmidt
* @copyright 2001-2019 WoltLab GmbH
* @license GNU Lesser General Public License
+ * @deprecated 6.3 Listen for the `UsersMerging` event instead.
*/
abstract class AbstractUserMergeListener implements IParameterizedEventListener
{
diff --git a/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserBulkInteractions.class.php b/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserBulkInteractions.class.php
index b43ae67206b..42e54bb6cb6 100644
--- a/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserBulkInteractions.class.php
+++ b/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserBulkInteractions.class.php
@@ -3,11 +3,11 @@
namespace wcf\system\interaction\bulk\admin;
use wcf\acp\action\UserContentRemoveAction;
+use wcf\acp\action\UserMergeAction;
use wcf\acp\form\UserAssignToGroupForm;
use wcf\acp\form\UserContentRevertChangesForm;
use wcf\acp\form\UserEmailAddressExportForm;
use wcf\acp\form\UserMailForm;
-use wcf\acp\form\UserMergeForm;
use wcf\action\UserBanAction;
use wcf\data\user\User;
use wcf\data\user\UserList;
@@ -82,9 +82,9 @@ public function __construct()
'wcf.acp.user.action.assignToGroup',
static fn(User $user) => $user->canEdit()
),
- new BulkLinkInteraction(
+ new BulkFormBuilderDialogInteraction(
'merge',
- UserMergeForm::class,
+ UserMergeAction::class,
'wcf.acp.user.action.merge',
static fn(User $user) => $user->canEdit()
),
diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml
index b55d6844ecd..9d8cdea0ad8 100644
--- a/wcfsetup/install/lang/de.xml
+++ b/wcfsetup/install/lang/de.xml
@@ -3069,7 +3069,6 @@ Abschnitte dürfen nicht leer sein und nur folgende Zeichen enthalten: [a-z
-
- Anzeigen zu aktualisieren.]]>
@@ -5830,5 +5829,6 @@ Erlaubte Dateiendungen: {', '|implode:$allowedFileExtensions}]]>
+
diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml
index 2baef94a52b..059fd43ec3d 100644
--- a/wcfsetup/install/lang/en.xml
+++ b/wcfsetup/install/lang/en.xml
@@ -2998,7 +2998,6 @@ If you have already bought the licenses for the listed apps , th
-
- rebuild data tasks in the maintenance section afterwards in order to solve any conflicts or inconsistencies.]]>
@@ -5832,5 +5831,6 @@ Allowed extensions: {', '|implode:$allowedFileExtensions}]]>
+
From 04f2429abe7a48babf1fe6492223dfb7ad5ff477 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Sun, 11 Oct 2026 22:42:29 +0200
Subject: [PATCH 086/115] Extract user merge column reassignment into
UserColumn/ReassignUserColumn and let UsersMerging listeners register columns
---
phpstan-baseline.neon | 6 --
.../lib/command/user/MergeUsers.class.php | 79 +++++++++----------
.../command/user/ReassignUserColumn.class.php | 55 +++++++++++++
.../lib/event/user/UsersMerging.class.php | 40 +++++++++-
.../AbstractUserMergeListener.class.php | 47 ++++-------
.../system/user/merge/UserColumn.class.php | 30 +++++++
6 files changed, 178 insertions(+), 79 deletions(-)
create mode 100644 wcfsetup/install/files/lib/command/user/ReassignUserColumn.class.php
create mode 100644 wcfsetup/install/files/lib/system/user/merge/UserColumn.class.php
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
index 886ad6e727f..93101f357c0 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -1465,12 +1465,6 @@ parameters:
count: 1
path: wcfsetup/install/files/lib/system/email/mime/RecipientAwareTextMimePart.class.php
- -
- message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
- identifier: empty.notAllowed
- count: 3
- path: wcfsetup/install/files/lib/system/event/listener/AbstractUserMergeListener.class.php
-
-
message: '#^Constructor of class wcf\\system\\exception\\ErrorException has an unused parameter \$filename\.$#'
identifier: constructor.unusedParameter
diff --git a/wcfsetup/install/files/lib/command/user/MergeUsers.class.php b/wcfsetup/install/files/lib/command/user/MergeUsers.class.php
index 153f0bbdc99..cebe3e49067 100644
--- a/wcfsetup/install/files/lib/command/user/MergeUsers.class.php
+++ b/wcfsetup/install/files/lib/command/user/MergeUsers.class.php
@@ -7,6 +7,7 @@
use wcf\event\user\UsersMerging;
use wcf\system\database\util\PreparedStatementConditionBuilder;
use wcf\system\event\EventHandler;
+use wcf\system\user\merge\UserColumn;
use wcf\system\WCF;
/**
@@ -47,29 +48,17 @@ public function __invoke(): void
return;
}
- $this->updateUserID('wcf1_poll_option_vote', ignore: true);
- $this->updateUserID('wcf1_comment');
- $this->updateUserID('wcf1_comment_response');
+ foreach ($this->getUserColumns() as $column) {
+ $this->reassign($column);
+ }
$this->updateProfileComments();
- $this->updateUserID('wcf1_like', ignore: true);
- $this->updateUserID('wcf1_like', 'objectUserID');
- $this->updateUserID('wcf1_like_object', 'objectUserID');
- $this->updateUserID('wcf1_user_follow', ignore: true, excludedColumnName: 'followUserID');
- $this->updateUserID('wcf1_user_follow', 'followUserID', true, 'userID');
- $this->updateUserID('wcf1_user_ignore', ignore: true, excludedColumnName: 'ignoreUserID');
- $this->updateUserID('wcf1_user_ignore', 'ignoreUserID', true, 'userID');
- $this->updateUserID('wcf1_user_object_watch', ignore: true);
- $this->updateUserID('wcf1_user_activity_event');
- $this->updateUserID('wcf1_attachment');
- $this->updateUserID('wcf1_modification_log');
- $this->updateUserID('wcf1_user_notification_author', 'authorID', true);
- $this->updateUserID('wcf1_article');
- $this->updateUserID('wcf1_moderation_queue');
- $this->updateUserID('wcf1_edit_history_entry');
- EventHandler::getInstance()->fire(
- new UsersMerging($this->destinationUser, $this->mergedUsers)
- );
+ $event = new UsersMerging($this->destinationUser, $this->mergedUsers);
+ EventHandler::getInstance()->fire($event);
+
+ foreach ($event->getUserColumns() as $column) {
+ $this->reassign($column);
+ }
foreach ($this->mergedUsers as $user) {
new DeleteUser($user)();
@@ -77,29 +66,35 @@ public function __invoke(): void
}
/**
- * Replaces the ids of the merged users in the given column with the id of
- * the destination user.
- *
- * Rows whose `$excludedColumnName` references the destination user are
- * skipped, e.g. to prevent the destination user from following themselves.
+ * @return list
*/
- private function updateUserID(
- string $tableName,
- string $columnName = 'userID',
- bool $ignore = false,
- ?string $excludedColumnName = null
- ): void {
- $conditions = new PreparedStatementConditionBuilder();
- $conditions->add("{$columnName} IN (?)", [$this->mergedUserIDs]);
- if ($excludedColumnName !== null) {
- $conditions->add("{$excludedColumnName} <> ?", [$this->destinationUser->userID]);
- }
+ private function getUserColumns(): array
+ {
+ return [
+ new UserColumn('wcf1_poll_option_vote', ignore: true),
+ new UserColumn('wcf1_comment', usernameColumn: 'username'),
+ new UserColumn('wcf1_comment_response', usernameColumn: 'username'),
+ new UserColumn('wcf1_like', ignore: true),
+ new UserColumn('wcf1_like', 'objectUserID'),
+ new UserColumn('wcf1_like_object', 'objectUserID'),
+ new UserColumn('wcf1_user_follow', ignore: true, excludedColumn: 'followUserID'),
+ new UserColumn('wcf1_user_follow', 'followUserID', ignore: true, excludedColumn: 'userID'),
+ new UserColumn('wcf1_user_ignore', ignore: true, excludedColumn: 'ignoreUserID'),
+ new UserColumn('wcf1_user_ignore', 'ignoreUserID', ignore: true, excludedColumn: 'userID'),
+ new UserColumn('wcf1_user_object_watch', ignore: true),
+ new UserColumn('wcf1_user_activity_event', usernameColumn: 'username'),
+ new UserColumn('wcf1_attachment'),
+ new UserColumn('wcf1_modification_log', usernameColumn: 'username'),
+ new UserColumn('wcf1_user_notification_author', 'authorID', ignore: true),
+ new UserColumn('wcf1_article', usernameColumn: 'username'),
+ new UserColumn('wcf1_moderation_queue'),
+ new UserColumn('wcf1_edit_history_entry', usernameColumn: 'username'),
+ ];
+ }
- $sql = "UPDATE" . ($ignore ? " IGNORE" : "") . " {$tableName}
- SET {$columnName} = ?
- " . $conditions;
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute(\array_merge([$this->destinationUser->userID], $conditions->getParameters()));
+ private function reassign(UserColumn $column): void
+ {
+ (new ReassignUserColumn($column, $this->destinationUser, $this->mergedUserIDs))();
}
private function updateProfileComments(): void
diff --git a/wcfsetup/install/files/lib/command/user/ReassignUserColumn.class.php b/wcfsetup/install/files/lib/command/user/ReassignUserColumn.class.php
new file mode 100644
index 00000000000..73153c1291a
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/user/ReassignUserColumn.class.php
@@ -0,0 +1,55 @@
+
+ * @since 6.3
+ */
+final class ReassignUserColumn
+{
+ /**
+ * @param int[] $mergedUserIDs
+ */
+ public function __construct(
+ private readonly UserColumn $column,
+ private readonly User $destinationUser,
+ private readonly array $mergedUserIDs,
+ ) {}
+
+ public function __invoke(): void
+ {
+ if ($this->mergedUserIDs === []) {
+ return;
+ }
+
+ $conditions = new PreparedStatementConditionBuilder();
+ $conditions->add("{$this->column->userIDColumn} IN (?)", [$this->mergedUserIDs]);
+ if ($this->column->excludedColumn !== null) {
+ $conditions->add("{$this->column->excludedColumn} <> ?", [$this->destinationUser->userID]);
+ }
+
+ $set = "{$this->column->userIDColumn} = ?";
+ $parameters = [$this->destinationUser->userID];
+ if ($this->column->usernameColumn !== null) {
+ $set .= ", {$this->column->usernameColumn} = ?";
+ $parameters[] = $this->destinationUser->username;
+ }
+
+ $sql = "UPDATE" . ($this->column->ignore ? " IGNORE" : "") . " {$this->column->tableName}
+ SET {$set}
+ " . $conditions;
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute(\array_merge($parameters, $conditions->getParameters()));
+ }
+}
diff --git a/wcfsetup/install/files/lib/event/user/UsersMerging.class.php b/wcfsetup/install/files/lib/event/user/UsersMerging.class.php
index c7ef24fb1ae..303b4d441ea 100644
--- a/wcfsetup/install/files/lib/event/user/UsersMerging.class.php
+++ b/wcfsetup/install/files/lib/event/user/UsersMerging.class.php
@@ -4,12 +4,15 @@
use wcf\data\user\User;
use wcf\event\IPsr14Event;
+use wcf\system\user\merge\UserColumn;
/**
* Indicates that users are being merged into a destination user.
*
* The event is fired before the merged users are deleted, allowing listeners
- * to reassign data from the merged users to the destination user.
+ * to reassign data from the merged users to the destination user. Columns
+ * registered through `reassignUserColumn()` are updated after all listeners
+ * have been executed.
*
* @author Marcel Werk
* @copyright 2001-2026 WoltLab GmbH
@@ -18,6 +21,11 @@
*/
final class UsersMerging implements IPsr14Event
{
+ /**
+ * @var list
+ */
+ private array $userColumns = [];
+
/**
* @param User[] $mergedUsers
*/
@@ -25,4 +33,34 @@ public function __construct(
public readonly User $destinationUser,
public readonly array $mergedUsers,
) {}
+
+ /**
+ * Registers a column whose references to the merged users are replaced
+ * with the destination user.
+ *
+ * @see UserColumn::__construct()
+ */
+ public function reassignUserColumn(
+ string $tableName,
+ string $userIDColumn = 'userID',
+ ?string $usernameColumn = null,
+ bool $ignore = false,
+ ?string $excludedColumn = null,
+ ): void {
+ $this->userColumns[] = new UserColumn(
+ $tableName,
+ $userIDColumn,
+ $usernameColumn,
+ $ignore,
+ $excludedColumn,
+ );
+ }
+
+ /**
+ * @return list
+ */
+ public function getUserColumns(): array
+ {
+ return $this->userColumns;
+ }
}
diff --git a/wcfsetup/install/files/lib/system/event/listener/AbstractUserMergeListener.class.php b/wcfsetup/install/files/lib/system/event/listener/AbstractUserMergeListener.class.php
index 52bc30f4a04..982a634561f 100644
--- a/wcfsetup/install/files/lib/system/event/listener/AbstractUserMergeListener.class.php
+++ b/wcfsetup/install/files/lib/system/event/listener/AbstractUserMergeListener.class.php
@@ -3,17 +3,17 @@
namespace wcf\system\event\listener;
use wcf\acp\form\UserMergeForm;
-use wcf\system\database\util\PreparedStatementConditionBuilder;
-use wcf\system\WCF;
+use wcf\command\user\ReassignUserColumn;
+use wcf\system\user\merge\UserColumn;
/**
* Abstract implementation of an event listener updating database tables during
* a user merge.
*
- * @author Matthias Schmidt
- * @copyright 2001-2019 WoltLab GmbH
+ * @author Matthias Schmidt, Marcel Werk
+ * @copyright 2001-2026 WoltLab GmbH
* @license GNU Lesser General Public License
- * @deprecated 6.3 Listen for the `UsersMerging` event instead.
+ * @deprecated 6.3 Listen for the `UsersMerging` event and use `UsersMerging::reassignUserColumn()` instead.
*/
abstract class AbstractUserMergeListener implements IParameterizedEventListener
{
@@ -24,10 +24,9 @@ abstract class AbstractUserMergeListener implements IParameterizedEventListener
* or an array with values `name` (database table name), `userID` (name of
* the database table column containing the id of the user), `username`
* (optional; database column name for the username if it also needs to be
- * updated, default value: username) and `ignore` (optional if an UPDATEI
- * GNORE query should be used)
+ * updated, default value: username) and `ignore` (optional if an UPDATE
+ * IGNORE query should be used)
* `{WCF_N}` will be automatically replaced with the number of the WCF installation
- * (only with PHP 5.6 string concatenation is possible in property declarations)
* @var array
*/
protected $databaseTables = [];
@@ -37,35 +36,23 @@ public function execute(mixed $eventObj, string $className, string $eventName, a
{
\assert($eventObj instanceof UserMergeForm);
+ $destinationUser = $eventObj->users[$eventObj->destinationUserID];
+
foreach ($this->databaseTables as $databaseTable) {
if (!\is_array($databaseTable)) {
$databaseTable = ['name' => $databaseTable];
}
- if (!isset($databaseTable['userID'])) {
- $databaseTable['userID'] = 'userID';
- }
- if (!\array_key_exists('username', $databaseTable)) {
- $databaseTable['username'] = 'username';
- }
- $conditionBuilder = new PreparedStatementConditionBuilder();
- $conditionBuilder->add($databaseTable['userID'] . " IN (?)", [$eventObj->mergedUserIDs]);
+ $usernameColumn = \array_key_exists('username', $databaseTable) ? $databaseTable['username'] : 'username';
- $parameters = [$eventObj->destinationUserID];
- if (!empty($databaseTable['username'])) {
- $parameters[] = $eventObj->users[$eventObj->destinationUserID]->username;
- }
+ $column = new UserColumn(
+ \str_replace('{WCF_N}', '1', $databaseTable['name']),
+ $databaseTable['userID'] ?? 'userID',
+ $usernameColumn !== '' ? $usernameColumn : null,
+ $databaseTable['ignore'] ?? false,
+ );
- $sql = "UPDATE" . (!empty($databaseTable['ignore']) ? " IGNORE" : "") . " " . \str_replace(
- '{WCF_N}',
- '1',
- $databaseTable['name']
- ) . "
- SET " . $databaseTable['userID'] . " = ?
- " . (!empty($databaseTable['username']) ? ", " . $databaseTable['username'] . " = ? " : "")
- . $conditionBuilder;
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute(\array_merge($parameters, $conditionBuilder->getParameters()));
+ (new ReassignUserColumn($column, $destinationUser, $eventObj->mergedUserIDs))();
}
}
}
diff --git a/wcfsetup/install/files/lib/system/user/merge/UserColumn.class.php b/wcfsetup/install/files/lib/system/user/merge/UserColumn.class.php
new file mode 100644
index 00000000000..89f6dfd0212
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/user/merge/UserColumn.class.php
@@ -0,0 +1,30 @@
+
+ * @since 6.3
+ */
+final class UserColumn
+{
+ /**
+ * @param string $tableName name of the database table
+ * @param string $userIDColumn name of the column containing the user id
+ * @param ?string $usernameColumn name of the column containing the username, which is updated alongside the user id
+ * @param bool $ignore uses `UPDATE IGNORE` to skip rows that would violate a unique key
+ * @param ?string $excludedColumn rows whose value in this column matches the destination user are skipped, e.g. to prevent the destination user from following themselves
+ */
+ public function __construct(
+ public readonly string $tableName,
+ public readonly string $userIDColumn = 'userID',
+ public readonly ?string $usernameColumn = null,
+ public readonly bool $ignore = false,
+ public readonly ?string $excludedColumn = null,
+ ) {}
+}
From 72e214e20c9658f680a4f619ecb392ec1ffd315b Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Sun, 11 Oct 2026 22:48:52 +0200
Subject: [PATCH 087/115] Hide user merge bulk interaction when fewer than two
users are selected
---
.../BulkInteractionContextMenuComponent.class.php | 7 ++++++-
.../bulk/admin/UserBulkInteractions.class.php | 14 ++++++++++++--
2 files changed, 18 insertions(+), 3 deletions(-)
diff --git a/wcfsetup/install/files/lib/system/interaction/bulk/BulkInteractionContextMenuComponent.class.php b/wcfsetup/install/files/lib/system/interaction/bulk/BulkInteractionContextMenuComponent.class.php
index 74350be1bb9..9586b569428 100644
--- a/wcfsetup/install/files/lib/system/interaction/bulk/BulkInteractionContextMenuComponent.class.php
+++ b/wcfsetup/install/files/lib/system/interaction/bulk/BulkInteractionContextMenuComponent.class.php
@@ -37,7 +37,12 @@ public function renderContextMenuOptions(array $objects): string
$objects,
static fn(DatabaseObject $object) => $interaction->isAvailable($object)
);
- $html .= '' . $interaction->render($availableForObjects) . ' ';
+ $option = $interaction->render($availableForObjects);
+ if ($option === '') {
+ continue;
+ }
+
+ $html .= '' . $option . ' ';
}
}
diff --git a/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserBulkInteractions.class.php b/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserBulkInteractions.class.php
index 42e54bb6cb6..02633a04db0 100644
--- a/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserBulkInteractions.class.php
+++ b/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserBulkInteractions.class.php
@@ -82,12 +82,22 @@ public function __construct()
'wcf.acp.user.action.assignToGroup',
static fn(User $user) => $user->canEdit()
),
- new BulkFormBuilderDialogInteraction(
+ new class(
'merge',
UserMergeAction::class,
'wcf.acp.user.action.merge',
static fn(User $user) => $user->canEdit()
- ),
+ ) extends BulkFormBuilderDialogInteraction {
+ #[\Override]
+ public function render(array $objects): string
+ {
+ if (\count($objects) < 2) {
+ return '';
+ }
+
+ return parent::render($objects);
+ }
+ },
new BulkLinkInteraction(
'send-mail',
UserMailForm::class,
From 6fe0d0702680e04a14eebf1f8d5adf9b28b37d2d Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Sun, 11 Oct 2026 22:57:13 +0200
Subject: [PATCH 088/115] Migrate user content revert changes to form builder
dialog
---
com.woltlab.wcf/acpTemplateDelete.xml | 1 +
com.woltlab.wcf/fileDelete.xml | 1 +
.../templates/userContentRevertChanges.tpl | 59 ---------
.../UserContentRevertChangesAction.class.php | 119 ++++++++++++++++++
.../UserContentRevertChangesForm.class.php | 119 ------------------
.../bulk/admin/UserBulkInteractions.class.php | 6 +-
wcfsetup/install/lang/de.xml | 4 +-
wcfsetup/install/lang/en.xml | 2 +-
8 files changed, 127 insertions(+), 184 deletions(-)
delete mode 100644 wcfsetup/install/files/acp/templates/userContentRevertChanges.tpl
create mode 100644 wcfsetup/install/files/lib/acp/action/UserContentRevertChangesAction.class.php
delete mode 100644 wcfsetup/install/files/lib/acp/form/UserContentRevertChangesForm.class.php
diff --git a/com.woltlab.wcf/acpTemplateDelete.xml b/com.woltlab.wcf/acpTemplateDelete.xml
index b9ef980a11e..b684099d9e7 100644
--- a/com.woltlab.wcf/acpTemplateDelete.xml
+++ b/com.woltlab.wcf/acpTemplateDelete.xml
@@ -160,5 +160,6 @@
removeUserContentDialog
userSearch
userMerge
+ userContentRevertChanges
diff --git a/com.woltlab.wcf/fileDelete.xml b/com.woltlab.wcf/fileDelete.xml
index 671b5cf60a8..8d3fe92c07c 100644
--- a/com.woltlab.wcf/fileDelete.xml
+++ b/com.woltlab.wcf/fileDelete.xml
@@ -112,6 +112,7 @@
lib/system/option/user/UserOptionHandler.class.php
lib/acp/form/UserOptionListForm.class.php
lib/system/condition/UserOptionsCondition.class.php
+ lib/acp/form/UserContentRevertChangesForm.class.php
icon/font-awesome/v6/brands/42-group.svg
icon/font-awesome/v6/brands/500px.svg
diff --git a/wcfsetup/install/files/acp/templates/userContentRevertChanges.tpl b/wcfsetup/install/files/acp/templates/userContentRevertChanges.tpl
deleted file mode 100644
index b4da692c034..00000000000
--- a/wcfsetup/install/files/acp/templates/userContentRevertChanges.tpl
+++ /dev/null
@@ -1,59 +0,0 @@
-{include file='header' pageTitle='wcf.acp.user.revertChanges'}
-
-
-
-{include file='shared_formError'}
-
-
-
-{include file='footer'}
diff --git a/wcfsetup/install/files/lib/acp/action/UserContentRevertChangesAction.class.php b/wcfsetup/install/files/lib/acp/action/UserContentRevertChangesAction.class.php
new file mode 100644
index 00000000000..bc3d39ac460
--- /dev/null
+++ b/wcfsetup/install/files/lib/acp/action/UserContentRevertChangesAction.class.php
@@ -0,0 +1,119 @@
+
+ * @since 6.3
+ */
+final class UserContentRevertChangesAction implements RequestHandlerInterface
+{
+ #[\Override]
+ public function handle(ServerRequestInterface $request): ResponseInterface
+ {
+ if (\MODULE_EDIT_HISTORY === 0) {
+ throw new IllegalLinkException();
+ }
+ if (!WCF::getSession()->hasPermission('admin.content.canBulkRevertContentChanges')) {
+ throw new PermissionDeniedException();
+ }
+
+ $users = $this->getUsers($request);
+
+ $form = $this->getForm();
+
+ if ($request->getMethod() === 'GET') {
+ return $form->toResponse();
+ } elseif ($request->getMethod() === 'POST') {
+ $response = $form->validateRequest($request);
+ if ($response !== null) {
+ return $response;
+ }
+
+ $timeframe = (int)$form->getData()['data']['timeframe'];
+
+ EditHistoryManager::getInstance()->bulkRevert(\array_keys($users), $timeframe * 86400);
+
+ return new JsonResponse([]);
+ } else {
+ throw new \LogicException('Unreachable');
+ }
+ }
+
+ /**
+ * @return array
+ */
+ private function getUsers(ServerRequestInterface $request): array
+ {
+ try {
+ $parameters = Helper::mapQueryParameters(
+ $request->getQueryParams(),
+ <<<'EOT'
+ array {
+ ids: non-empty-list
+ }
+ EOT
+ );
+ } catch (MappingError) {
+ throw new IllegalLinkException();
+ }
+
+ $userIDs = \array_unique($parameters['ids']);
+
+ $userList = new UserList();
+ $userList->setObjectIDs($userIDs);
+ $userList->readObjects();
+ $users = $userList->getObjects();
+ if (\count($users) !== \count($userIDs)) {
+ throw new IllegalLinkException();
+ }
+
+ foreach ($users as $user) {
+ if (!UserGroup::isAccessibleGroup($user->getGroupIDs())) {
+ throw new PermissionDeniedException();
+ }
+ }
+
+ return $users;
+ }
+
+ private function getForm(): Psr15DialogForm
+ {
+ $form = new Psr15DialogForm(
+ static::class,
+ WCF::getLanguage()->get('wcf.acp.user.revertChanges')
+ );
+ $form->appendChildren([
+ IntegerFormField::create('timeframe')
+ ->label('wcf.acp.user.revertChanges.timeframe')
+ ->description('wcf.acp.user.revertChanges.timeframe.description')
+ ->suffix('wcf.acp.option.suffix.days')
+ ->minimum(1)
+ ->value(7)
+ ->required(),
+ ]);
+ $form->build();
+
+ return $form;
+ }
+}
diff --git a/wcfsetup/install/files/lib/acp/form/UserContentRevertChangesForm.class.php b/wcfsetup/install/files/lib/acp/form/UserContentRevertChangesForm.class.php
deleted file mode 100644
index a4891bb78cd..00000000000
--- a/wcfsetup/install/files/lib/acp/form/UserContentRevertChangesForm.class.php
+++ /dev/null
@@ -1,119 +0,0 @@
-
- */
-class UserContentRevertChangesForm extends AbstractForm
-{
- use TMultipleUsersForm;
-
- /**
- * @inheritDoc
- */
- public $neededModules = ['MODULE_EDIT_HISTORY'];
-
- /**
- * @inheritDoc
- */
- public $neededPermissions = ['admin.content.canBulkRevertContentChanges'];
-
- /**
- * @inheritDoc
- */
- public $activeMenuItem = 'wcf.acp.menu.link.user.list';
-
- /**
- * ids of the relevant users
- * @var int[]
- */
- public $userIDs = [];
-
- /**
- * relevant users
- * @var User[]
- */
- public $users = [];
-
- /**
- * timeframe to consider
- * @var int
- */
- public $timeframe = 7;
-
- #[\Override]
- public function readParameters()
- {
- parent::readParameters();
-
- $this->users = $this->readUsersFromRequest();
- foreach ($this->users as $user) {
- if (!UserGroup::isAccessibleGroup($user->getGroupIDs())) {
- throw new PermissionDeniedException();
- }
- }
- $this->userIDs = \array_keys($this->users);
- }
-
- #[\Override]
- public function readFormParameters()
- {
- parent::readFormParameters();
-
- if (isset($_POST['timeframe'])) {
- $this->timeframe = \intval($_POST['timeframe']);
- }
- }
-
- #[\Override]
- public function validate()
- {
- parent::validate();
-
- if ($this->timeframe < 1) {
- throw new UserInputException('timeframe');
- }
- }
-
- #[\Override]
- public function save()
- {
- parent::save();
-
- EditHistoryManager::getInstance()->bulkRevert($this->userIDs, $this->timeframe * 86400);
-
- // reset clipboard
- $this->saved();
-
- // show success message
- WCF::getTPL()->assign('message', 'wcf.global.success');
- WCF::getTPL()->display('success');
-
- exit;
- }
-
- #[\Override]
- public function assignVariables()
- {
- parent::assignVariables();
-
- WCF::getTPL()->assign([
- 'users' => $this->users,
- 'userIDs' => $this->userIDs,
- 'timeframe' => $this->timeframe,
- ]);
- }
-}
diff --git a/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserBulkInteractions.class.php b/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserBulkInteractions.class.php
index 02633a04db0..11473c2841a 100644
--- a/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserBulkInteractions.class.php
+++ b/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserBulkInteractions.class.php
@@ -3,9 +3,9 @@
namespace wcf\system\interaction\bulk\admin;
use wcf\acp\action\UserContentRemoveAction;
+use wcf\acp\action\UserContentRevertChangesAction;
use wcf\acp\action\UserMergeAction;
use wcf\acp\form\UserAssignToGroupForm;
-use wcf\acp\form\UserContentRevertChangesForm;
use wcf\acp\form\UserEmailAddressExportForm;
use wcf\acp\form\UserMailForm;
use wcf\action\UserBanAction;
@@ -118,9 +118,9 @@ public function render(array $objects): string
'wcf.acp.user.action.sendNewPassword.confirmMessage.bulk',
static fn(User $user) => $user->canSendNewPassword()
),
- new BulkLinkInteraction(
+ new BulkFormBuilderDialogInteraction(
'revert-content-changes',
- UserContentRevertChangesForm::class,
+ UserContentRevertChangesAction::class,
'wcf.acp.user.action.revertContentChanges',
static fn() => \MODULE_EDIT_HISTORY !== 0
&& WCF::getSession()->hasPermission('admin.content.canBulkRevertContentChanges')
diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml
index 9d8cdea0ad8..348429b94cb 100644
--- a/wcfsetup/install/lang/de.xml
+++ b/wcfsetup/install/lang/de.xml
@@ -3072,8 +3072,7 @@ Abschnitte dürfen nicht leer sein und nur folgende Zeichen enthalten: [a-z
- Anzeigen zu aktualisieren.]]>
-
-
+
@@ -5830,5 +5829,6 @@ Erlaubte Dateiendungen: {', '|implode:$allowedFileExtensions}]]>
+
diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml
index 059fd43ec3d..22ae6f4057c 100644
--- a/wcfsetup/install/lang/en.xml
+++ b/wcfsetup/install/lang/en.xml
@@ -3002,7 +3002,6 @@ If you have already bought the licenses for the listed apps , th
-
@@ -5832,5 +5831,6 @@ Allowed extensions: {', '|implode:$allowedFileExtensions}]]>
+
From 7fd48668d37996145e511d30f511ca5f053752f8 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Sun, 11 Oct 2026 23:00:44 +0200
Subject: [PATCH 089/115] Migrate user assign to group to form builder dialog
---
com.woltlab.wcf/acpTemplateDelete.xml | 1 +
com.woltlab.wcf/fileDelete.xml | 1 +
.../files/acp/templates/userAssignToGroup.tpl | 56 ------
.../action/UserAssignToGroupAction.class.php | 123 ++++++++++++
.../acp/form/UserAssignToGroupForm.class.php | 183 ------------------
.../bulk/admin/UserBulkInteractions.class.php | 6 +-
wcfsetup/install/lang/de.xml | 4 +-
wcfsetup/install/lang/en.xml | 4 +-
8 files changed, 132 insertions(+), 246 deletions(-)
delete mode 100644 wcfsetup/install/files/acp/templates/userAssignToGroup.tpl
create mode 100644 wcfsetup/install/files/lib/acp/action/UserAssignToGroupAction.class.php
delete mode 100755 wcfsetup/install/files/lib/acp/form/UserAssignToGroupForm.class.php
diff --git a/com.woltlab.wcf/acpTemplateDelete.xml b/com.woltlab.wcf/acpTemplateDelete.xml
index b684099d9e7..14eba04a54d 100644
--- a/com.woltlab.wcf/acpTemplateDelete.xml
+++ b/com.woltlab.wcf/acpTemplateDelete.xml
@@ -161,5 +161,6 @@
userSearch
userMerge
userContentRevertChanges
+ userAssignToGroup
diff --git a/com.woltlab.wcf/fileDelete.xml b/com.woltlab.wcf/fileDelete.xml
index 8d3fe92c07c..25718c4b2ed 100644
--- a/com.woltlab.wcf/fileDelete.xml
+++ b/com.woltlab.wcf/fileDelete.xml
@@ -113,6 +113,7 @@
lib/acp/form/UserOptionListForm.class.php
lib/system/condition/UserOptionsCondition.class.php
lib/acp/form/UserContentRevertChangesForm.class.php
+ lib/acp/form/UserAssignToGroupForm.class.php
icon/font-awesome/v6/brands/42-group.svg
icon/font-awesome/v6/brands/500px.svg
diff --git a/wcfsetup/install/files/acp/templates/userAssignToGroup.tpl b/wcfsetup/install/files/acp/templates/userAssignToGroup.tpl
deleted file mode 100644
index 2bcf9d09e82..00000000000
--- a/wcfsetup/install/files/acp/templates/userAssignToGroup.tpl
+++ /dev/null
@@ -1,56 +0,0 @@
-{include file='header' pageTitle='wcf.acp.user.assignToGroup'}
-
-
-
-{include file='shared_formError'}
-
-
-
-{include file='footer'}
diff --git a/wcfsetup/install/files/lib/acp/action/UserAssignToGroupAction.class.php b/wcfsetup/install/files/lib/acp/action/UserAssignToGroupAction.class.php
new file mode 100644
index 00000000000..3db01df5dc0
--- /dev/null
+++ b/wcfsetup/install/files/lib/acp/action/UserAssignToGroupAction.class.php
@@ -0,0 +1,123 @@
+
+ * @since 6.3
+ */
+final class UserAssignToGroupAction implements RequestHandlerInterface
+{
+ #[\Override]
+ public function handle(ServerRequestInterface $request): ResponseInterface
+ {
+ $users = $this->getUsers($request);
+
+ $form = $this->getForm();
+
+ if ($request->getMethod() === 'GET') {
+ return $form->toResponse();
+ } elseif ($request->getMethod() === 'POST') {
+ $response = $form->validateRequest($request);
+ if ($response !== null) {
+ return $response;
+ }
+
+ $groupIDs = \array_map(
+ static fn($groupID) => (int)$groupID,
+ $form->getData()['data']['groupIDs']
+ );
+
+ (new UserAction(\array_values($users), 'addToGroups', [
+ 'groups' => $groupIDs,
+ 'deleteOldGroups' => false,
+ 'addDefaultGroups' => false,
+ ]))->executeAction();
+
+ return new JsonResponse([]);
+ } else {
+ throw new \LogicException('Unreachable');
+ }
+ }
+
+ /**
+ * @return array
+ */
+ private function getUsers(ServerRequestInterface $request): array
+ {
+ try {
+ $parameters = Helper::mapQueryParameters(
+ $request->getQueryParams(),
+ <<<'EOT'
+ array {
+ ids: non-empty-list
+ }
+ EOT
+ );
+ } catch (MappingError) {
+ throw new IllegalLinkException();
+ }
+
+ $userIDs = \array_unique($parameters['ids']);
+
+ $userList = new UserList();
+ $userList->setObjectIDs($userIDs);
+ $userList->readObjects();
+ $users = $userList->getObjects();
+ if (\count($users) !== \count($userIDs)) {
+ throw new IllegalLinkException();
+ }
+
+ foreach ($users as $user) {
+ if (!$user->canEdit()) {
+ throw new PermissionDeniedException();
+ }
+ }
+
+ return $users;
+ }
+
+ private function getForm(): Psr15DialogForm
+ {
+ $form = new Psr15DialogForm(
+ static::class,
+ WCF::getLanguage()->get('wcf.acp.user.assignToGroup')
+ );
+ $form->appendChildren([
+ MultipleSelectionFormField::create('groupIDs')
+ ->label('wcf.acp.user.groups')
+ ->options(
+ UserGroup::getSortedAccessibleGroups(
+ [],
+ [UserGroup::GUESTS, UserGroup::EVERYONE, UserGroup::USERS]
+ ),
+ false,
+ false
+ )
+ ->required(),
+ ]);
+ $form->build();
+
+ return $form;
+ }
+}
diff --git a/wcfsetup/install/files/lib/acp/form/UserAssignToGroupForm.class.php b/wcfsetup/install/files/lib/acp/form/UserAssignToGroupForm.class.php
deleted file mode 100755
index 42ad5f0df4c..00000000000
--- a/wcfsetup/install/files/lib/acp/form/UserAssignToGroupForm.class.php
+++ /dev/null
@@ -1,183 +0,0 @@
-
- */
-class UserAssignToGroupForm extends AbstractForm
-{
- use TMultipleUsersForm;
-
- /**
- * @inheritDoc
- */
- public $neededPermissions = ['admin.user.canEditUser'];
-
- /**
- * @inheritDoc
- */
- public $activeMenuItem = 'wcf.acp.menu.link.user.list';
-
- /**
- * ids of the relevant users
- * @var int[]
- */
- public $userIDs = [];
-
- /**
- * ids of the assigned user groups
- * @var int[]
- */
- public $groupIDs = [];
-
- /**
- * relevant users
- * @var User[]
- */
- public $users = [];
-
- /**
- * assigned user groups
- * @var UserGroup[]
- */
- public $groups = [];
-
- #[\Override]
- public function readParameters()
- {
- parent::readParameters();
-
- $this->users = $this->readUsersFromRequest();
- foreach ($this->users as $user) {
- if (!UserGroup::isAccessibleGroup($user->getGroupIDs())) {
- throw new PermissionDeniedException();
- }
- }
-
- $this->userIDs = \array_keys($this->users);
- }
-
- #[\Override]
- public function readFormParameters()
- {
- parent::readFormParameters();
-
- if (isset($_POST['groupIDs']) && \is_array($_POST['groupIDs'])) {
- $this->groupIDs = ArrayUtil::toIntegerArray($_POST['groupIDs']);
- }
- }
-
- #[\Override]
- public function validate()
- {
- parent::validate();
-
- if ($this->userIDs === []) {
- throw new IllegalLinkException();
- }
-
- // groups
- foreach ($this->groupIDs as $groupID) {
- $group = new UserGroup($groupID);
- if ($group->isNil()) {
- throw new UserInputException('groupIDs');
- }
- if (!$group->isAccessible()) {
- throw new PermissionDeniedException();
- }
- }
- }
-
- #[\Override]
- public function save()
- {
- parent::save();
-
- $conditions = new PreparedStatementConditionBuilder();
- $conditions->add("userID IN (?)", [$this->userIDs]);
-
- $sql = "SELECT userID, groupID
- FROM wcf1_user_to_group
- " . $conditions;
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute($conditions->getParameters());
- $groups = $statement->fetchMap('userID', 'groupID', false);
-
- foreach ($this->users as $user) {
- if (!UserGroup::isAccessibleGroup($groups[$user->userID])) {
- throw new PermissionDeniedException();
- }
-
- $groupsIDs = \array_merge($groups[$user->userID], $this->groupIDs);
- $groupsIDs = \array_unique($groupsIDs);
-
- $action = new UserAction([new UserEditor($user)], 'addToGroups', [
- 'groups' => $groupsIDs,
- 'addDefaultGroups' => false,
- ]);
- $action->executeAction();
- }
-
- $this->saved();
-
- WCF::getTPL()->assign([
- 'groupIDs' => $this->groupIDs,
- 'message' => 'wcf.acp.user.assignToGroup.success',
- 'users' => $this->users,
- ]);
- WCF::getTPL()->display('success');
-
- exit;
- }
-
- #[\Override]
- public function readData()
- {
- parent::readData();
-
- $this->readGroups();
- }
-
- #[\Override]
- public function assignVariables()
- {
- parent::assignVariables();
-
- WCF::getTPL()->assign([
- 'users' => $this->users,
- 'userIDs' => $this->userIDs,
- 'groupIDs' => $this->groupIDs,
- 'groups' => $this->groups,
- ]);
- }
-
- /**
- * Get a list of available groups.
- *
- * @return void
- */
- protected function readGroups()
- {
- $this->groups = UserGroup::getSortedAccessibleGroups(
- [],
- [UserGroup::GUESTS, UserGroup::EVERYONE, UserGroup::USERS]
- );
- }
-}
diff --git a/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserBulkInteractions.class.php b/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserBulkInteractions.class.php
index 11473c2841a..8804620f214 100644
--- a/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserBulkInteractions.class.php
+++ b/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserBulkInteractions.class.php
@@ -2,10 +2,10 @@
namespace wcf\system\interaction\bulk\admin;
+use wcf\acp\action\UserAssignToGroupAction;
use wcf\acp\action\UserContentRemoveAction;
use wcf\acp\action\UserContentRevertChangesAction;
use wcf\acp\action\UserMergeAction;
-use wcf\acp\form\UserAssignToGroupForm;
use wcf\acp\form\UserEmailAddressExportForm;
use wcf\acp\form\UserMailForm;
use wcf\action\UserBanAction;
@@ -76,9 +76,9 @@ public function __construct()
isAvailableCallback: static fn(User $user) => $user->banned === 1
&& $user->canBan()
),
- new BulkLinkInteraction(
+ new BulkFormBuilderDialogInteraction(
'assign-to-group',
- UserAssignToGroupForm::class,
+ UserAssignToGroupAction::class,
'wcf.acp.user.action.assignToGroup',
static fn(User $user) => $user->canEdit()
),
diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml
index 348429b94cb..701ef66f6d1 100644
--- a/wcfsetup/install/lang/de.xml
+++ b/wcfsetup/install/lang/de.xml
@@ -3041,8 +3041,6 @@ Abschnitte dürfen nicht leer sein und nur folgende Zeichen enthalten: [a-z
-
- - 1}Die{else}Der{/if} Benutzer wurde{if $users|count > 1}n{/if} erfolgreich {if $groupIDs|count > 1}den ausgewählten Benutzergruppen{else}der ausgewählten Benutzergruppe{/if} zugeordnet.]]>
@@ -5830,5 +5828,7 @@ Erlaubte Dateiendungen: {', '|implode:$allowedFileExtensions}]]>
+
+
diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml
index 22ae6f4057c..7b88c5f3218 100644
--- a/wcfsetup/install/lang/en.xml
+++ b/wcfsetup/install/lang/en.xml
@@ -2970,8 +2970,6 @@ If you have already bought the licenses for the listed apps , th
-
- - 1}s have{else} has{/if} successfully been added to the selected user group{if $groupIDs|count > 1}s{/if}.]]>
@@ -5832,5 +5830,7 @@ Allowed extensions: {', '|implode:$allowedFileExtensions}]]>
+
+
From cefc51c32b6694b5cb3fb7fcb231c166c04e766b Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Sun, 11 Oct 2026 23:07:42 +0200
Subject: [PATCH 090/115] Migrate user email address export to form builder
dialog
---
com.woltlab.wcf/acpTemplateDelete.xml | 1 +
com.woltlab.wcf/fileDelete.xml | 1 +
.../Acp/Component/User/EmailAddressExport.ts | 52 +++++
.../acp/templates/userEmailAddressExport.tpl | 67 -------
.../Acp/Component/User/EmailAddressExport.js | 41 ++++
.../UserEmailAddressExportAction.class.php | 177 ++++++++++++++++++
.../lib/acp/form/TMultipleUsersForm.class.php | 47 -----
.../form/UserEmailAddressExportForm.class.php | 140 --------------
.../bulk/admin/UserBulkInteractions.class.php | 23 ++-
wcfsetup/install/lang/de.xml | 4 +-
wcfsetup/install/lang/en.xml | 4 +-
11 files changed, 295 insertions(+), 262 deletions(-)
create mode 100644 ts/WoltLabSuite/Core/Acp/Component/User/EmailAddressExport.ts
delete mode 100644 wcfsetup/install/files/acp/templates/userEmailAddressExport.tpl
create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Component/User/EmailAddressExport.js
create mode 100644 wcfsetup/install/files/lib/acp/action/UserEmailAddressExportAction.class.php
delete mode 100644 wcfsetup/install/files/lib/acp/form/TMultipleUsersForm.class.php
delete mode 100755 wcfsetup/install/files/lib/acp/form/UserEmailAddressExportForm.class.php
diff --git a/com.woltlab.wcf/acpTemplateDelete.xml b/com.woltlab.wcf/acpTemplateDelete.xml
index 14eba04a54d..9bf79c79679 100644
--- a/com.woltlab.wcf/acpTemplateDelete.xml
+++ b/com.woltlab.wcf/acpTemplateDelete.xml
@@ -162,5 +162,6 @@
userMerge
userContentRevertChanges
userAssignToGroup
+ userEmailAddressExport
diff --git a/com.woltlab.wcf/fileDelete.xml b/com.woltlab.wcf/fileDelete.xml
index 25718c4b2ed..2f838089c41 100644
--- a/com.woltlab.wcf/fileDelete.xml
+++ b/com.woltlab.wcf/fileDelete.xml
@@ -114,6 +114,7 @@
lib/system/condition/UserOptionsCondition.class.php
lib/acp/form/UserContentRevertChangesForm.class.php
lib/acp/form/UserAssignToGroupForm.class.php
+ lib/acp/form/UserEmailAddressExportForm.class.php
icon/font-awesome/v6/brands/42-group.svg
icon/font-awesome/v6/brands/500px.svg
diff --git a/ts/WoltLabSuite/Core/Acp/Component/User/EmailAddressExport.ts b/ts/WoltLabSuite/Core/Acp/Component/User/EmailAddressExport.ts
new file mode 100644
index 00000000000..548ad837145
--- /dev/null
+++ b/ts/WoltLabSuite/Core/Acp/Component/User/EmailAddressExport.ts
@@ -0,0 +1,52 @@
+/**
+ * Exports the email addresses of one or more users and offers the resulting
+ * file for download.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.3
+ */
+
+import { dialogFactory } from "WoltLabSuite/Core/Component/Dialog";
+
+type Result = {
+ filename: string;
+ mimeType: string;
+ content: string;
+};
+
+async function exportEmailAddresses(endpoint: string): Promise {
+ const { ok, result } = await dialogFactory().usingFormBuilder().fromEndpoint(endpoint);
+ if (!ok) {
+ return false;
+ }
+
+ const url = window.URL.createObjectURL(new Blob([result.content], { type: `${result.mimeType};charset=utf-8` }));
+
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = result.filename;
+ link.click();
+
+ // Revoking the URL synchronously can abort the download in some browsers.
+ window.setTimeout(() => window.URL.revokeObjectURL(url), 0);
+
+ return true;
+}
+
+export function setupBulk(identifier: string, container: HTMLElement): void {
+ container.addEventListener("bulk-interaction", (event: CustomEvent>) => {
+ if (event.detail.bulkInteraction !== identifier) {
+ return;
+ }
+
+ void exportEmailAddresses(event.detail.endpoint).then((ok) => {
+ if (!ok) {
+ return;
+ }
+
+ container.dispatchEvent(new CustomEvent("interaction:reset-selection"));
+ });
+ });
+}
diff --git a/wcfsetup/install/files/acp/templates/userEmailAddressExport.tpl b/wcfsetup/install/files/acp/templates/userEmailAddressExport.tpl
deleted file mode 100644
index d6c2e3eb528..00000000000
--- a/wcfsetup/install/files/acp/templates/userEmailAddressExport.tpl
+++ /dev/null
@@ -1,67 +0,0 @@
-{include file='header' pageTitle='wcf.acp.user.exportEmailAddress'}
-
-
-
-{include file='shared_formError'}
-
-
-
-{include file='footer'}
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Component/User/EmailAddressExport.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Component/User/EmailAddressExport.js
new file mode 100644
index 00000000000..d23fdb7e583
--- /dev/null
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Component/User/EmailAddressExport.js
@@ -0,0 +1,41 @@
+/**
+ * Exports the email addresses of one or more users and offers the resulting
+ * file for download.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.3
+ */
+define(["require", "exports", "WoltLabSuite/Core/Component/Dialog"], function (require, exports, Dialog_1) {
+ "use strict";
+ Object.defineProperty(exports, "__esModule", { value: true });
+ exports.setupBulk = setupBulk;
+ async function exportEmailAddresses(endpoint) {
+ const { ok, result } = await (0, Dialog_1.dialogFactory)().usingFormBuilder().fromEndpoint(endpoint);
+ if (!ok) {
+ return false;
+ }
+ const url = window.URL.createObjectURL(new Blob([result.content], { type: `${result.mimeType};charset=utf-8` }));
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = result.filename;
+ link.click();
+ // Revoking the URL synchronously can abort the download in some browsers.
+ window.setTimeout(() => window.URL.revokeObjectURL(url), 0);
+ return true;
+ }
+ function setupBulk(identifier, container) {
+ container.addEventListener("bulk-interaction", (event) => {
+ if (event.detail.bulkInteraction !== identifier) {
+ return;
+ }
+ void exportEmailAddresses(event.detail.endpoint).then((ok) => {
+ if (!ok) {
+ return;
+ }
+ container.dispatchEvent(new CustomEvent("interaction:reset-selection"));
+ });
+ });
+ }
+});
diff --git a/wcfsetup/install/files/lib/acp/action/UserEmailAddressExportAction.class.php b/wcfsetup/install/files/lib/acp/action/UserEmailAddressExportAction.class.php
new file mode 100644
index 00000000000..5947becab66
--- /dev/null
+++ b/wcfsetup/install/files/lib/acp/action/UserEmailAddressExportAction.class.php
@@ -0,0 +1,177 @@
+
+ * @since 6.3
+ */
+final class UserEmailAddressExportAction implements RequestHandlerInterface
+{
+ #[\Override]
+ public function handle(ServerRequestInterface $request): ResponseInterface
+ {
+ if (!WCF::getSession()->hasPermission('admin.user.canEditMailAddress')) {
+ throw new PermissionDeniedException();
+ }
+
+ $users = $this->getUsers($request);
+
+ $form = $this->getForm();
+
+ if ($request->getMethod() === 'GET') {
+ return $form->toResponse();
+ } elseif ($request->getMethod() === 'POST') {
+ $response = $form->validateRequest($request);
+ if ($response !== null) {
+ return $response;
+ }
+
+ $data = $form->getData()['data'];
+ $fileType = (string)$data['fileType'];
+ if ($fileType === 'xml') {
+ $content = $this->getXml($users);
+ } else {
+ $content = $this->getCsv($users, (string)$data['separator'], (string)$data['textSeparator']);
+ }
+
+ return new JsonResponse([
+ 'result' => [
+ 'filename' => 'export.' . $fileType,
+ 'mimeType' => 'text/' . $fileType,
+ 'content' => $content,
+ ],
+ ]);
+ } else {
+ throw new \LogicException('Unreachable');
+ }
+ }
+
+ /**
+ * Returns the users sorted by their email address.
+ *
+ * @return array
+ */
+ private function getUsers(ServerRequestInterface $request): array
+ {
+ try {
+ $parameters = Helper::mapQueryParameters(
+ $request->getQueryParams(),
+ <<<'EOT'
+ array {
+ ids: non-empty-list
+ }
+ EOT
+ );
+ } catch (MappingError) {
+ throw new IllegalLinkException();
+ }
+
+ $userIDs = \array_unique($parameters['ids']);
+
+ $userList = new UserList();
+ $userList->setObjectIDs($userIDs);
+ $userList->sqlOrderBy = 'user_table.email';
+ $userList->readObjects();
+ $users = $userList->getObjects();
+ if (\count($users) !== \count($userIDs)) {
+ throw new IllegalLinkException();
+ }
+
+ return $users;
+ }
+
+ /**
+ * @param array $users
+ */
+ private function getCsv(array $users, string $separator, string $textSeparator): string
+ {
+ $escape = static fn(string $value) => $textSeparator
+ . \str_replace($textSeparator, $textSeparator . $textSeparator, $value)
+ . $textSeparator;
+
+ $content = '';
+ foreach ($users as $user) {
+ $content .= $escape($user->username) . $separator . $escape($user->email) . "\n";
+ }
+
+ return $content;
+ }
+
+ /**
+ * @param array $users
+ */
+ private function getXml(array $users): string
+ {
+ $content = "\n\n";
+ foreach ($users as $user) {
+ $content .= \sprintf(
+ " \n",
+ StringUtil::escapeCDATA($user->username),
+ StringUtil::escapeCDATA($user->email)
+ );
+ }
+ $content .= ' ';
+
+ return $content;
+ }
+
+ private function getForm(): Psr15DialogForm
+ {
+ $form = new Psr15DialogForm(
+ static::class,
+ WCF::getLanguage()->get('wcf.acp.user.exportEmailAddress')
+ );
+ $form->appendChildren([
+ RadioButtonFormField::create('fileType')
+ ->label('wcf.acp.user.exportEmailAddress.fileType')
+ ->options([
+ 'csv' => 'wcf.acp.user.exportEmailAddress.fileType.csv',
+ 'xml' => 'wcf.acp.user.exportEmailAddress.fileType.xml',
+ ])
+ ->value('csv')
+ ->required(),
+ TextFormField::create('separator')
+ ->label('wcf.acp.user.exportEmailAddress.separator')
+ ->value(',')
+ ->addDependency(
+ ValueFormFieldDependency::create('fileType')
+ ->fieldId('fileType')
+ ->values(['csv'])
+ ),
+ TextFormField::create('textSeparator')
+ ->label('wcf.acp.user.exportEmailAddress.textSeparator')
+ ->value('"')
+ ->addDependency(
+ ValueFormFieldDependency::create('fileType')
+ ->fieldId('fileType')
+ ->values(['csv'])
+ ),
+ ]);
+ $form->build();
+
+ return $form;
+ }
+}
diff --git a/wcfsetup/install/files/lib/acp/form/TMultipleUsersForm.class.php b/wcfsetup/install/files/lib/acp/form/TMultipleUsersForm.class.php
deleted file mode 100644
index 96b8d5b3db5..00000000000
--- a/wcfsetup/install/files/lib/acp/form/TMultipleUsersForm.class.php
+++ /dev/null
@@ -1,47 +0,0 @@
-
- * @since 6.3
- */
-trait TMultipleUsersForm
-{
- /**
- * @return array
- * @throws IllegalLinkException if no ids are given or any of the users does not exist
- */
- protected function readUsersFromRequest(): array
- {
- $userIDs = [];
- if (isset($_REQUEST['ids']) && \is_array($_REQUEST['ids'])) {
- $userIDs = \array_unique(ArrayUtil::toIntegerArray($_REQUEST['ids']));
- }
-
- if ($userIDs === []) {
- throw new IllegalLinkException();
- }
-
- $userList = new UserList();
- $userList->setObjectIDs($userIDs);
- $userList->readObjects();
- $users = $userList->getObjects();
-
- if (\count($users) !== \count($userIDs)) {
- throw new IllegalLinkException();
- }
-
- return $users;
- }
-}
diff --git a/wcfsetup/install/files/lib/acp/form/UserEmailAddressExportForm.class.php b/wcfsetup/install/files/lib/acp/form/UserEmailAddressExportForm.class.php
deleted file mode 100755
index 88599610f88..00000000000
--- a/wcfsetup/install/files/lib/acp/form/UserEmailAddressExportForm.class.php
+++ /dev/null
@@ -1,140 +0,0 @@
-
- */
-class UserEmailAddressExportForm extends AbstractForm
-{
- use TMultipleUsersForm;
-
- /**
- * @inheritDoc
- */
- public $activeMenuItem = 'wcf.acp.menu.link.user.list';
-
- /**
- * @inheritDoc
- */
- public $neededPermissions = ['admin.user.canEditMailAddress'];
-
- /**
- * type of the file containing the exported email addresses
- * @var string
- */
- public $fileType = 'csv';
-
- /**
- * ids of the users whose email addresses are exported
- * @var int[]
- */
- public $userIDs = [];
-
- /**
- * string used to separate email adresses
- * @var string
- */
- public $separator = ',';
-
- /**
- * string used to wrap email adresses
- * @var string
- */
- public $textSeparator = '"';
-
- /**
- * users whose email addresses are exported
- * @var User[]
- */
- public $users = [];
-
- #[\Override]
- public function readParameters()
- {
- parent::readParameters();
-
- $this->users = $this->readUsersFromRequest();
- $this->userIDs = \array_keys($this->users);
- }
-
- #[\Override]
- public function readFormParameters()
- {
- parent::readFormParameters();
-
- if (isset($_POST['fileType']) && $_POST['fileType'] === 'xml') {
- $this->fileType = $_POST['fileType'];
- }
- if (isset($_POST['separator'])) {
- $this->separator = $_POST['separator'];
- }
- if (isset($_POST['textSeparator'])) {
- $this->textSeparator = $_POST['textSeparator'];
- }
- }
-
- #[\Override]
- public function save()
- {
- parent::save();
-
- // send content type
- \header('Content-Type: text/' . $this->fileType . '; charset=UTF-8');
- \header('Content-Disposition: attachment; filename="export.' . $this->fileType . '"');
-
- if ($this->fileType === 'xml') {
- echo "\n\n";
- }
-
- $conditions = new PreparedStatementConditionBuilder();
- $conditions->add("userID IN (?)", [$this->userIDs]);
-
- // get users
- $sql = "SELECT username, email
- FROM wcf1_user
- " . $conditions . "
- ORDER BY email";
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute($conditions->getParameters());
-
- while ($row = $statement->fetchArray()) {
- if ($this->fileType === 'xml') {
- echo " \n";
- } else {
- echo $this->textSeparator . \str_replace($this->textSeparator, $this->textSeparator . $this->textSeparator, $row['username']) . $this->textSeparator . $this->separator . $this->textSeparator . \str_replace($this->textSeparator, $this->textSeparator . $this->textSeparator, $row['email']) . $this->textSeparator . "\n";
- }
- }
-
- if ($this->fileType === 'xml') {
- echo " ";
- }
-
- $this->saved();
-
- exit;
- }
-
- #[\Override]
- public function assignVariables()
- {
- parent::assignVariables();
-
- WCF::getTPL()->assign([
- 'users' => $this->users,
- 'separator' => $this->separator,
- 'textSeparator' => $this->textSeparator,
- 'fileType' => $this->fileType,
- ]);
- }
-}
diff --git a/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserBulkInteractions.class.php b/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserBulkInteractions.class.php
index 8804620f214..62df6e3cda2 100644
--- a/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserBulkInteractions.class.php
+++ b/wcfsetup/install/files/lib/system/interaction/bulk/admin/UserBulkInteractions.class.php
@@ -5,8 +5,8 @@
use wcf\acp\action\UserAssignToGroupAction;
use wcf\acp\action\UserContentRemoveAction;
use wcf\acp\action\UserContentRevertChangesAction;
+use wcf\acp\action\UserEmailAddressExportAction;
use wcf\acp\action\UserMergeAction;
-use wcf\acp\form\UserEmailAddressExportForm;
use wcf\acp\form\UserMailForm;
use wcf\action\UserBanAction;
use wcf\data\user\User;
@@ -104,12 +104,27 @@ public function render(array $objects): string
'wcf.acp.user.action.sendMail',
static fn(User $user) => $user->canMail()
),
- new BulkLinkInteraction(
+ new class(
'export-mail-address',
- UserEmailAddressExportForm::class,
+ UserEmailAddressExportAction::class,
'wcf.acp.user.action.exportMailAddress',
static fn() => WCF::getSession()->hasPermission('admin.user.canEditMailAddress')
- ),
+ ) extends BulkFormBuilderDialogInteraction {
+ #[\Override]
+ public function renderInitialization(string $containerId): string
+ {
+ $identifier = StringUtil::encodeJS($this->getIdentifier());
+ $containerId = StringUtil::encodeJS($containerId);
+
+ return <<
+ require(['WoltLabSuite/Core/Acp/Component/User/EmailAddressExport'], ({ setupBulk }) => {
+ setupBulk('{$identifier}', document.getElementById('{$containerId}'));
+ });
+
+ HTML;
+ }
+ },
new BulkRpcInteraction(
'send-new-password',
'core/users/%s/send-new-password',
diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml
index 701ef66f6d1..949195d28fd 100644
--- a/wcfsetup/install/lang/de.xml
+++ b/wcfsetup/install/lang/de.xml
@@ -3054,8 +3054,6 @@ Abschnitte dürfen nicht leer sein und nur folgende Zeichen enthalten: [a-z
- Character Separated Values)]]>
- Extensible Markup Language)]]>
-
-
@@ -5830,5 +5828,7 @@ Erlaubte Dateiendungen: {', '|implode:$allowedFileExtensions}]]>
+
+
diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml
index 7b88c5f3218..3f61d4f8106 100644
--- a/wcfsetup/install/lang/en.xml
+++ b/wcfsetup/install/lang/en.xml
@@ -2983,8 +2983,6 @@ If you have already bought the licenses for the listed apps , th
- Character Separated Values)]]>
- Extensible Markup Language)]]>
-
-
@@ -5832,5 +5830,7 @@ Allowed extensions: {', '|implode:$allowedFileExtensions}]]>
+
+
From 473cd1c55e4856c1d8b55f241d746566e13d7039 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Sun, 11 Oct 2026 23:18:52 +0200
Subject: [PATCH 091/115] Inline user group, language and option persistence
into UserBuilder
---
.../files/lib/data/user/UserBuilder.class.php | 90 ++++++++++++++++---
1 file changed, 77 insertions(+), 13 deletions(-)
diff --git a/wcfsetup/install/files/lib/data/user/UserBuilder.class.php b/wcfsetup/install/files/lib/data/user/UserBuilder.class.php
index 7881dad8dd1..8ace628dc49 100644
--- a/wcfsetup/install/files/lib/data/user/UserBuilder.class.php
+++ b/wcfsetup/install/files/lib/data/user/UserBuilder.class.php
@@ -5,8 +5,10 @@
use ParagonIE\ConstantTime\Hex;
use wcf\data\DatabaseObject;
use wcf\data\DatabaseObjectBuilder;
+use wcf\data\user\group\UserGroup;
use wcf\system\attachment\AttachmentHandler;
use wcf\system\html\input\HtmlInputProcessor;
+use wcf\system\language\LanguageFactory;
use wcf\system\message\embedded\object\MessageEmbeddedObjectManager;
use wcf\system\user\authentication\password\algorithm\Invalid as InvalidPasswordAlgorithm;
use wcf\system\user\authentication\password\PasswordAlgorithmManager;
@@ -406,14 +408,12 @@ protected function getRequiredProperties(): array
protected function afterCreate(DatabaseObject $object): void
{
$this->createUserOptions($object);
-
- $editor = new UserEditor($object);
- if ($this->groupIDs === null) {
- $editor->addToGroups([], false, true);
- } else {
- $editor->addToGroups($this->groupIDs, false, false);
- }
- $editor->addToLanguages($this->languageIDs ?? [], false);
+ $this->saveGroupIDs(
+ $object,
+ $this->groupIDs ?? UserGroup::getGroupIDsByType([UserGroup::EVERYONE, UserGroup::USERS]),
+ false
+ );
+ $this->saveLanguageIDs($object, $this->languageIDs ?? [], false);
if (isset($this->signatureHtmlInputProcessor)) {
$this->registerSignatureEmbeddedObjects($object);
@@ -438,18 +438,16 @@ protected function afterCreate(DatabaseObject $object): void
#[\Override]
protected function afterUpdate(DatabaseObject $object): void
{
- $editor = new UserEditor($object);
-
if ($this->userOptions !== null) {
- $editor->updateUserOptions($this->userOptions);
+ $this->updateUserOptions($object);
}
if ($this->groupIDs !== null) {
- $editor->addToGroups($this->groupIDs, true, false);
+ $this->saveGroupIDs($object, $this->groupIDs, true);
}
if ($this->languageIDs !== null) {
- $editor->addToLanguages($this->languageIDs, true);
+ $this->saveLanguageIDs($object, $this->languageIDs, true);
}
if (isset($this->signatureHtmlInputProcessor)) {
@@ -500,6 +498,72 @@ private function createUserOptions(User $user): void
$statement->execute($statementParameters);
}
+ private function updateUserOptions(User $user): void
+ {
+ $columns = [];
+ $statementParameters = [];
+ foreach ($this->userOptions as $optionID => $value) {
+ $columns[] = 'userOption' . $optionID . ' = ?';
+ $statementParameters[] = $value;
+ }
+ $statementParameters[] = $user->userID;
+
+ $sql = "UPDATE wcf1_user_option_value
+ SET " . \implode(', ', $columns) . "
+ WHERE userID = ?";
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute($statementParameters);
+ }
+
+ /**
+ * @param list $groupIDs
+ */
+ private function saveGroupIDs(User $user, array $groupIDs, bool $replace): void
+ {
+ if ($replace) {
+ $sql = "DELETE FROM wcf1_user_to_group
+ WHERE userID = ?";
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute([$user->userID]);
+ }
+
+ $sql = "INSERT IGNORE INTO wcf1_user_to_group
+ (userID, groupID)
+ VALUES (?, ?)";
+ $statement = WCF::getDB()->prepare($sql);
+ foreach ($groupIDs as $groupID) {
+ $statement->execute([$user->userID, $groupID]);
+ }
+ }
+
+ /**
+ * Saves the content languages of the user, an empty list falls back to the
+ * default language.
+ *
+ * @param list $languageIDs
+ */
+ private function saveLanguageIDs(User $user, array $languageIDs, bool $replace): void
+ {
+ if ($replace) {
+ $sql = "DELETE FROM wcf1_user_to_language
+ WHERE userID = ?";
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute([$user->userID]);
+ }
+
+ if ($languageIDs === []) {
+ $languageIDs = [LanguageFactory::getInstance()->getDefaultLanguageID()];
+ }
+
+ $sql = "INSERT IGNORE INTO wcf1_user_to_language
+ (userID, languageID)
+ VALUES (?, ?)";
+ $statement = WCF::getDB()->prepare($sql);
+ foreach ($languageIDs as $languageID) {
+ $statement->execute([$user->userID, $languageID]);
+ }
+ }
+
private static function getPasswordHash(
#[\SensitiveParameter]
?string $password
From 40c8e83c37bca7eff5d02cd37a4c86b95b59aea3 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Sun, 11 Oct 2026 23:27:37 +0200
Subject: [PATCH 092/115] Extract user rank refresh into RefreshUserRanks
command
---
.../acp/install_com.woltlab.wcf_step2.php | 4 +-
.../lib/command/user/CreateUser.class.php | 3 +-
.../command/user/RefreshUserRanks.class.php | 79 +++++++++++++++++++
.../lib/command/user/UpdateUser.class.php | 3 +-
.../lib/data/user/UserProfileAction.class.php | 53 ++-----------
.../point/UserActivityPointHandler.class.php | 15 +++-
6 files changed, 102 insertions(+), 55 deletions(-)
create mode 100644 wcfsetup/install/files/lib/command/user/RefreshUserRanks.class.php
diff --git a/wcfsetup/install/files/acp/install_com.woltlab.wcf_step2.php b/wcfsetup/install/files/acp/install_com.woltlab.wcf_step2.php
index 30135defc23..b1b66a15229 100644
--- a/wcfsetup/install/files/acp/install_com.woltlab.wcf_step2.php
+++ b/wcfsetup/install/files/acp/install_com.woltlab.wcf_step2.php
@@ -1,6 +1,7 @@
executeAction();
+(new RefreshUserRanks([WCF::getUser()]))();
$action = new UserProfileAction([$editor], 'updateUserOnlineMarking');
$action->executeAction();
diff --git a/wcfsetup/install/files/lib/command/user/CreateUser.class.php b/wcfsetup/install/files/lib/command/user/CreateUser.class.php
index 986166fbff6..fdacbf87839 100644
--- a/wcfsetup/install/files/lib/command/user/CreateUser.class.php
+++ b/wcfsetup/install/files/lib/command/user/CreateUser.class.php
@@ -28,8 +28,7 @@ public function __invoke(): User
$user = new User($this->builder->create()->userID);
if (\MODULE_USER_RANK !== 0) {
- $action = new UserProfileAction([$user], 'updateUserRank');
- $action->executeAction();
+ (new RefreshUserRanks([$user]))();
}
$action = new UserProfileAction([$user], 'updateUserOnlineMarking');
$action->executeAction();
diff --git a/wcfsetup/install/files/lib/command/user/RefreshUserRanks.class.php b/wcfsetup/install/files/lib/command/user/RefreshUserRanks.class.php
new file mode 100644
index 00000000000..357a401ca97
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/user/RefreshUserRanks.class.php
@@ -0,0 +1,79 @@
+
+ * @since 6.3
+ */
+final class RefreshUserRanks
+{
+ /**
+ * @param list $users
+ */
+ public function __construct(
+ private readonly array $users,
+ ) {}
+
+ public function __invoke(): void
+ {
+ $userToRank = [];
+ foreach ($this->users as $user) {
+ $rankID = $this->getRankID($user);
+ if ($rankID !== $user->rankID) {
+ $userToRank[$user->userID] = $rankID;
+ }
+ }
+
+ if ($userToRank === []) {
+ return;
+ }
+
+ $sql = "UPDATE wcf1_user
+ SET rankID = ?
+ WHERE userID = ?";
+ $statement = WCF::getDB()->prepare($sql);
+
+ WCF::getDB()->beginTransaction();
+ foreach ($userToRank as $userID => $rankID) {
+ $statement->execute([
+ $rankID,
+ $userID,
+ ]);
+ }
+ WCF::getDB()->commitTransaction();
+ }
+
+ private function getRankID(User $user): ?int
+ {
+ $conditionBuilder = new PreparedStatementConditionBuilder();
+ $conditionBuilder->add('user_rank.groupID IN (?)', [$user->getGroupIDs()]);
+ $conditionBuilder->add('user_rank.requiredPoints <= ?', [$user->activityPoints]);
+ if ((int)$user->gender !== 0) {
+ $conditionBuilder->add('user_rank.requiredGender IN (?)', [[0, $user->gender]]);
+ } else {
+ $conditionBuilder->add('user_rank.requiredGender = ?', [0]);
+ }
+
+ $sql = "SELECT user_rank.rankID
+ FROM wcf1_user_rank user_rank
+ LEFT JOIN wcf1_user_group user_group
+ ON user_group.groupID = user_rank.groupID
+ " . $conditionBuilder . "
+ ORDER BY user_group.priority DESC, user_rank.requiredPoints DESC, user_rank.requiredGender DESC";
+ $statement = WCF::getDB()->prepare($sql, 1);
+ $statement->execute($conditionBuilder->getParameters());
+ $rankID = $statement->fetchSingleColumn();
+
+ return $rankID === false ? null : (int)$rankID;
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/user/UpdateUser.class.php b/wcfsetup/install/files/lib/command/user/UpdateUser.class.php
index 465b26d67d1..c28f2fcb434 100644
--- a/wcfsetup/install/files/lib/command/user/UpdateUser.class.php
+++ b/wcfsetup/install/files/lib/command/user/UpdateUser.class.php
@@ -66,8 +66,7 @@ public function __invoke(): User
UserGroupAssignmentHandler::getInstance()->checkUsers([$user->userID]);
if (\MODULE_USER_RANK !== 0) {
- $action = new UserProfileAction([$user], 'updateUserRank');
- $action->executeAction();
+ (new RefreshUserRanks([$user]))();
}
}
diff --git a/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php b/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php
index eac85974f68..5573966f5e4 100644
--- a/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php
+++ b/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php
@@ -2,6 +2,7 @@
namespace wcf\data\user;
+use wcf\command\user\RefreshUserRanks;
use wcf\data\object\type\ObjectTypeCache;
use wcf\data\user\group\UserGroup;
use wcf\system\bbcode\BBCodeHandler;
@@ -178,6 +179,7 @@ public function getDetailedActivityPointList()
* Updates user ranks.
*
* @return void
+ * @deprecated 6.3 use the `RefreshUserRanks` command instead.
*/
public function updateUserRank()
{
@@ -185,52 +187,11 @@ public function updateUserRank()
$this->readObjects();
}
- $userToRank = [];
- foreach ($this->getObjects() as $user) {
- $conditionBuilder = new PreparedStatementConditionBuilder();
- $conditionBuilder->add('user_rank.groupID IN (?)', [$user->getGroupIDs()]);
- $conditionBuilder->add('user_rank.requiredPoints <= ?', [$user->activityPoints]);
- if ((int)$user->gender !== 0) {
- $conditionBuilder->add('user_rank.requiredGender IN (?)', [[0, $user->gender]]);
- } else {
- $conditionBuilder->add('user_rank.requiredGender = ?', [0]);
- }
-
- $sql = "SELECT user_rank.rankID
- FROM wcf1_user_rank user_rank
- LEFT JOIN wcf1_user_group user_group
- ON user_group.groupID = user_rank.groupID
- " . $conditionBuilder . "
- ORDER BY user_group.priority DESC, user_rank.requiredPoints DESC, user_rank.requiredGender DESC";
- $statement = WCF::getDB()->prepare($sql, 1);
- $statement->execute($conditionBuilder->getParameters());
- $row = $statement->fetchArray();
- if ($row === false) {
- if ($user->rankID !== null) {
- $userToRank[$user->userID] = null;
- }
- } else {
- if ($row['rankID'] !== $user->rankID) {
- $userToRank[$user->userID] = $row['rankID'];
- }
- }
- }
-
- if ($userToRank !== []) {
- $sql = "UPDATE wcf1_user
- SET rankID = ?
- WHERE userID = ?";
- $statement = WCF::getDB()->prepare($sql);
-
- WCF::getDB()->beginTransaction();
- foreach ($userToRank as $userID => $rankID) {
- $statement->execute([
- $rankID,
- $userID,
- ]);
- }
- WCF::getDB()->commitTransaction();
- }
+ $users = \array_map(
+ static fn (UserEditor $editor) => $editor->getDecoratedObject(),
+ $this->getObjects()
+ );
+ (new RefreshUserRanks(\array_values($users)))();
}
/**
diff --git a/wcfsetup/install/files/lib/system/user/activity/point/UserActivityPointHandler.class.php b/wcfsetup/install/files/lib/system/user/activity/point/UserActivityPointHandler.class.php
index 6b6e6306a46..2199d55f52a 100644
--- a/wcfsetup/install/files/lib/system/user/activity/point/UserActivityPointHandler.class.php
+++ b/wcfsetup/install/files/lib/system/user/activity/point/UserActivityPointHandler.class.php
@@ -2,9 +2,10 @@
namespace wcf\system\user\activity\point;
+use wcf\command\user\RefreshUserRanks;
use wcf\data\object\type\ObjectType;
use wcf\data\object\type\ObjectTypeCache;
-use wcf\data\user\UserProfileAction;
+use wcf\data\user\UserList;
use wcf\system\database\util\PreparedStatementConditionBuilder;
use wcf\system\exception\InvalidObjectTypeException;
use wcf\system\exception\SystemException;
@@ -278,7 +279,15 @@ public function getObjectTypeByName(string $objectType)
*/
protected function updateUserRanks(array $userIDs)
{
- $action = new UserProfileAction($userIDs, 'updateUserRank');
- $action->executeAction();
+ if ($userIDs === []) {
+ return;
+ }
+
+ // Read the users from the database, the cached objects lack the new activity points.
+ $userList = new UserList();
+ $userList->setObjectIDs($userIDs);
+ $userList->readObjects();
+
+ (new RefreshUserRanks(\array_values($userList->getObjects())))();
}
}
From c188f9bea31f4fa4de8690901641db447f4ac448 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Sun, 11 Oct 2026 23:33:18 +0200
Subject: [PATCH 093/115] Extract user online marking refresh into
RefreshUserOnlineMarkings command
---
.../acp/install_com.woltlab.wcf_step2.php | 7 +-
.../lib/command/user/CreateUser.class.php | 4 +-
.../user/RefreshUserOnlineMarkings.class.php | 131 ++++++++++++++++++
.../lib/command/user/UpdateUser.class.php | 4 +-
.../lib/data/user/UserProfileAction.class.php | 114 +--------------
.../worker/UserRebuildDataWorker.class.php | 8 +-
6 files changed, 144 insertions(+), 124 deletions(-)
create mode 100644 wcfsetup/install/files/lib/command/user/RefreshUserOnlineMarkings.class.php
diff --git a/wcfsetup/install/files/acp/install_com.woltlab.wcf_step2.php b/wcfsetup/install/files/acp/install_com.woltlab.wcf_step2.php
index b1b66a15229..e890953b50e 100644
--- a/wcfsetup/install/files/acp/install_com.woltlab.wcf_step2.php
+++ b/wcfsetup/install/files/acp/install_com.woltlab.wcf_step2.php
@@ -1,13 +1,12 @@
executeAction();
+(new RefreshUserOnlineMarkings([WCF::getUser()]))();
// install default reactions
foreach ([
diff --git a/wcfsetup/install/files/lib/command/user/CreateUser.class.php b/wcfsetup/install/files/lib/command/user/CreateUser.class.php
index fdacbf87839..01b7913b680 100644
--- a/wcfsetup/install/files/lib/command/user/CreateUser.class.php
+++ b/wcfsetup/install/files/lib/command/user/CreateUser.class.php
@@ -4,7 +4,6 @@
use wcf\data\user\User;
use wcf\data\user\UserBuilder;
-use wcf\data\user\UserProfileAction;
use wcf\event\user\UserCreated;
use wcf\system\event\EventHandler;
@@ -30,8 +29,7 @@ public function __invoke(): User
if (\MODULE_USER_RANK !== 0) {
(new RefreshUserRanks([$user]))();
}
- $action = new UserProfileAction([$user], 'updateUserOnlineMarking');
- $action->executeAction();
+ (new RefreshUserOnlineMarkings([$user]))();
EventHandler::getInstance()->fire(new UserCreated($user, $this->builder));
diff --git a/wcfsetup/install/files/lib/command/user/RefreshUserOnlineMarkings.class.php b/wcfsetup/install/files/lib/command/user/RefreshUserOnlineMarkings.class.php
new file mode 100644
index 00000000000..a4d6f9f6049
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/user/RefreshUserOnlineMarkings.class.php
@@ -0,0 +1,131 @@
+
+ * @since 6.3
+ */
+final class RefreshUserOnlineMarkings
+{
+ /**
+ * @param list $users
+ */
+ public function __construct(
+ private readonly array $users,
+ ) {}
+
+ public function __invoke(): void
+ {
+ $addToGroupIDs = $removeFromGroupIDs = $newGroupIDs = $userToGroup = [];
+ foreach ($this->users as $user) {
+ $groupIDs = $user->getGroupIDs();
+
+ if ($user->pendingActivation()) {
+ $requiredGroupIDs = [UserGroup::EVERYONE, UserGroup::GUESTS];
+ $invalidGroupID = UserGroup::USERS;
+ } else {
+ $requiredGroupIDs = [UserGroup::EVERYONE, UserGroup::USERS];
+ $invalidGroupID = UserGroup::GUESTS;
+ }
+
+ $missingGroupIDs = \array_values(\array_diff($requiredGroupIDs, $groupIDs));
+ if ($missingGroupIDs !== []) {
+ $addToGroupIDs[$user->userID] = $missingGroupIDs;
+ $groupIDs = \array_merge($groupIDs, $missingGroupIDs);
+ }
+
+ if (\in_array($invalidGroupID, $groupIDs, true)) {
+ $removeFromGroupIDs[$user->userID] = $invalidGroupID;
+ $groupIDs = \array_values(\array_diff($groupIDs, [$invalidGroupID]));
+ }
+
+ if (isset($addToGroupIDs[$user->userID]) || isset($removeFromGroupIDs[$user->userID])) {
+ $newGroupIDs[$user->userID] = $groupIDs;
+ }
+
+ $groupID = $this->getOnlineGroupID($groupIDs);
+ if ($groupID !== $user->userOnlineGroupID) {
+ $userToGroup[$user->userID] = $groupID;
+ }
+ }
+
+ if ($addToGroupIDs !== []) {
+ $sql = "INSERT INTO wcf1_user_to_group
+ (userID, groupID)
+ VALUES (?, ?)";
+ $statement = WCF::getDB()->prepare($sql);
+
+ WCF::getDB()->beginTransaction();
+ foreach ($addToGroupIDs as $userID => $groupIDs) {
+ foreach ($groupIDs as $groupID) {
+ $statement->execute([$userID, $groupID]);
+ }
+ }
+ WCF::getDB()->commitTransaction();
+ }
+
+ if ($removeFromGroupIDs !== []) {
+ $sql = "DELETE FROM wcf1_user_to_group
+ WHERE userID = ?
+ AND groupID = ?";
+ $statement = WCF::getDB()->prepare($sql);
+
+ WCF::getDB()->beginTransaction();
+ foreach ($removeFromGroupIDs as $userID => $groupID) {
+ $statement->execute([$userID, $groupID]);
+ }
+ WCF::getDB()->commitTransaction();
+ }
+
+ foreach ($newGroupIDs as $userID => $groupIDs) {
+ UserStorageHandler::getInstance()->update($userID, 'groupIDs', \serialize($groupIDs));
+ }
+
+ if ($userToGroup !== []) {
+ $sql = "UPDATE wcf1_user
+ SET userOnlineGroupID = ?
+ WHERE userID = ?";
+ $statement = WCF::getDB()->prepare($sql);
+
+ WCF::getDB()->beginTransaction();
+ foreach ($userToGroup as $userID => $groupID) {
+ $statement->execute([
+ $groupID,
+ $userID,
+ ]);
+ }
+ WCF::getDB()->commitTransaction();
+ }
+ }
+
+ /**
+ * @param int[] $groupIDs
+ */
+ private function getOnlineGroupID(array $groupIDs): int
+ {
+ $conditionBuilder = new PreparedStatementConditionBuilder();
+ $conditionBuilder->add('groupID IN (?)', [$groupIDs]);
+
+ $sql = "SELECT groupID
+ FROM wcf1_user_group
+ " . $conditionBuilder . "
+ ORDER BY priority DESC";
+ $statement = WCF::getDB()->prepare($sql, 1);
+ $statement->execute($conditionBuilder->getParameters());
+
+ return (int)$statement->fetchSingleColumn();
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/user/UpdateUser.class.php b/wcfsetup/install/files/lib/command/user/UpdateUser.class.php
index c28f2fcb434..b4e744f1318 100644
--- a/wcfsetup/install/files/lib/command/user/UpdateUser.class.php
+++ b/wcfsetup/install/files/lib/command/user/UpdateUser.class.php
@@ -4,7 +4,6 @@
use wcf\data\user\User;
use wcf\data\user\UserBuilder;
-use wcf\data\user\UserProfileAction;
use wcf\event\user\UserRenamed;
use wcf\event\user\UserUpdated;
use wcf\system\event\EventHandler;
@@ -71,8 +70,7 @@ public function __invoke(): User
}
if ($groupsChanged && \MODULE_USERS_ONLINE !== 0) {
- $action = new UserProfileAction([$user], 'updateUserOnlineMarking');
- $action->executeAction();
+ (new RefreshUserOnlineMarkings([$user]))();
}
EventHandler::getInstance()->fire(new UserUpdated($user, $this->builder));
diff --git a/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php b/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php
index 5573966f5e4..5c8fb306b1c 100644
--- a/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php
+++ b/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php
@@ -2,9 +2,9 @@
namespace wcf\data\user;
+use wcf\command\user\RefreshUserOnlineMarkings;
use wcf\command\user\RefreshUserRanks;
use wcf\data\object\type\ObjectTypeCache;
-use wcf\data\user\group\UserGroup;
use wcf\system\bbcode\BBCodeHandler;
use wcf\system\cache\runtime\UserProfileRuntimeCache;
use wcf\system\database\util\PreparedStatementConditionBuilder;
@@ -198,6 +198,7 @@ public function updateUserRank()
* Updates user online markings.
*
* @return void
+ * @deprecated 6.3 use the `RefreshUserOnlineMarkings` command instead.
*/
public function updateUserOnlineMarking()
{
@@ -205,112 +206,11 @@ public function updateUserOnlineMarking()
$this->readObjects();
}
- $fixUserGroupIDs = $userToGroup = $removeFromGroupIDs = [];
- $newGroupIDs = [];
- foreach ($this->getObjects() as $user) {
- $groupIDs = $user->getGroupIDs();
- if (!\in_array(UserGroup::EVERYONE, $groupIDs, true)) {
- $fixUserGroupIDs[$user->userID] = [UserGroup::EVERYONE];
- $groupIDs[] = UserGroup::EVERYONE;
- }
- if ($user->pendingActivation()) {
- if (!\in_array(UserGroup::GUESTS, $groupIDs, true)) {
- if (!isset($fixUserGroupIDs[$user->userID])) {
- $fixUserGroupIDs[$user->userID] = [];
- }
- $fixUserGroupIDs[$user->userID][] = UserGroup::GUESTS;
- $groupIDs[] = UserGroup::GUESTS;
- }
-
- if (\in_array(UserGroup::USERS, $groupIDs, true)) {
- if (!isset($removeFromGroupIDs[$user->userID])) {
- $removeFromGroupIDs[$user->userID] = [];
- }
-
- $removeFromGroupIDs[$user->userID][] = UserGroup::USERS;
- }
- } else {
- if (!\in_array(UserGroup::USERS, $groupIDs, true)) {
- if (!isset($fixUserGroupIDs[$user->userID])) {
- $fixUserGroupIDs[$user->userID] = [];
- }
- $fixUserGroupIDs[$user->userID][] = UserGroup::USERS;
- $groupIDs[] = UserGroup::USERS;
- }
-
- if (\in_array(UserGroup::GUESTS, $groupIDs, true)) {
- if (!isset($removeFromGroupIDs[$user->userID])) {
- $removeFromGroupIDs[$user->userID] = [];
- }
-
- $removeFromGroupIDs[$user->userID][] = UserGroup::GUESTS;
- }
- }
- $newGroupIDs[$user->userID] = $groupIDs;
-
- $conditionBuilder = new PreparedStatementConditionBuilder();
- $conditionBuilder->add('groupID IN (?)', [$groupIDs]);
-
- $sql = "SELECT groupID
- FROM wcf1_user_group
- " . $conditionBuilder . "
- ORDER BY priority DESC";
- $statement = WCF::getDB()->prepare($sql, 1);
- $statement->execute($conditionBuilder->getParameters());
- $row = $statement->fetchArray();
- if ($row['groupID'] !== $user->userOnlineGroupID) {
- $userToGroup[$user->userID] = $row['groupID'];
- }
- }
-
- // add users to missing default user groups
- if ($fixUserGroupIDs !== []) {
- $sql = "INSERT INTO wcf1_user_to_group
- (userID, groupID)
- VALUES (?, ?)";
- $statement = WCF::getDB()->prepare($sql);
-
- WCF::getDB()->beginTransaction();
- foreach ($fixUserGroupIDs as $userID => $groupIDs) {
- foreach ($groupIDs as $groupID) {
- $statement->execute([$userID, $groupID]);
- }
-
- UserStorageHandler::getInstance()->update($userID, 'groupIDs', \serialize($newGroupIDs[$userID]));
- }
- WCF::getDB()->commitTransaction();
- }
-
- if ($removeFromGroupIDs !== []) {
- $sql = "DELETE FROM wcf1_user_to_group
- WHERE userID = ?
- AND groupID = ?";
- $statement = WCF::getDB()->prepare($sql);
-
- WCF::getDB()->beginTransaction();
- foreach ($removeFromGroupIDs as $userID => $groupIDs) {
- foreach ($groupIDs as $groupID) {
- $statement->execute([$userID, $groupID]);
- }
- }
- WCF::getDB()->commitTransaction();
- }
-
- if ($userToGroup !== []) {
- $sql = "UPDATE wcf1_user
- SET userOnlineGroupID = ?
- WHERE userID = ?";
- $statement = WCF::getDB()->prepare($sql);
-
- WCF::getDB()->beginTransaction();
- foreach ($userToGroup as $userID => $groupID) {
- $statement->execute([
- $groupID,
- $userID,
- ]);
- }
- WCF::getDB()->commitTransaction();
- }
+ $users = \array_map(
+ static fn (UserEditor $editor) => $editor->getDecoratedObject(),
+ $this->getObjects()
+ );
+ (new RefreshUserOnlineMarkings(\array_values($users)))();
}
/**
diff --git a/wcfsetup/install/files/lib/system/worker/UserRebuildDataWorker.class.php b/wcfsetup/install/files/lib/system/worker/UserRebuildDataWorker.class.php
index 9b415c57e32..a2f83c079c9 100644
--- a/wcfsetup/install/files/lib/system/worker/UserRebuildDataWorker.class.php
+++ b/wcfsetup/install/files/lib/system/worker/UserRebuildDataWorker.class.php
@@ -3,6 +3,7 @@
namespace wcf\system\worker;
use wcf\command\file\CreateFileFromExistingFile;
+use wcf\command\user\RefreshUserOnlineMarkings;
use wcf\data\file\FileBuilder;
use wcf\data\file\FileList;
use wcf\data\reaction\type\ReactionTypeCache;
@@ -13,7 +14,6 @@
use wcf\data\user\UserEditor;
use wcf\data\user\UserList;
use wcf\data\user\UserProfile;
-use wcf\data\user\UserProfileAction;
use wcf\system\bbcode\BBCodeHandler;
use wcf\system\database\util\PreparedStatementConditionBuilder;
use wcf\system\exception\SystemException;
@@ -72,11 +72,7 @@ public function execute()
$userIDs[] = $user->userID;
}
- // update user ranks
- if ($users !== []) {
- $action = new UserProfileAction($users, 'updateUserOnlineMarking');
- $action->executeAction();
- }
+ (new RefreshUserOnlineMarkings(\array_values($this->getObjectList()->getObjects())))();
$this->updateUserOnlineStatus($users);
From 5e20845fc5905b9ff67ba57dc2c4403591d58222 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Sun, 11 Oct 2026 23:36:47 +0200
Subject: [PATCH 094/115] Migrate detailed activity point list to RPC endpoint
---
.../Api/Users/GetUserActivityPointList.ts | 23 +++++
.../Core/Component/User/ActivityPointList.ts | 12 +--
.../Api/Users/GetUserActivityPointList.js | 19 ++++
.../Core/Component/User/ActivityPointList.js | 8 +-
.../lib/data/user/UserProfileAction.class.php | 2 +
.../users/GetUserActivityPointList.class.php | 87 +++++++++++++++++++
6 files changed, 137 insertions(+), 14 deletions(-)
create mode 100644 ts/WoltLabSuite/Core/Api/Users/GetUserActivityPointList.ts
create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Api/Users/GetUserActivityPointList.js
create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/users/GetUserActivityPointList.class.php
diff --git a/ts/WoltLabSuite/Core/Api/Users/GetUserActivityPointList.ts b/ts/WoltLabSuite/Core/Api/Users/GetUserActivityPointList.ts
new file mode 100644
index 00000000000..cc18c865ed3
--- /dev/null
+++ b/ts/WoltLabSuite/Core/Api/Users/GetUserActivityPointList.ts
@@ -0,0 +1,23 @@
+/**
+ * Gets the html code for the rendering of the detailed activity point list of a user.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.3
+ */
+
+import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend";
+import { fromInfallibleApiRequest } from "WoltLabSuite/Core/Api/Result";
+
+type Response = {
+ template: string;
+};
+
+export async function getUserActivityPointList(userId: number): Promise {
+ const url = new URL(`${window.WSC_RPC_API_URL}core/users/${userId}/activity-points`);
+
+ return fromInfallibleApiRequest(() => {
+ return prepareRequest(url).get().fetchAsJson();
+ });
+}
diff --git a/ts/WoltLabSuite/Core/Component/User/ActivityPointList.ts b/ts/WoltLabSuite/Core/Component/User/ActivityPointList.ts
index 886c55914a2..7ab67f59626 100644
--- a/ts/WoltLabSuite/Core/Component/User/ActivityPointList.ts
+++ b/ts/WoltLabSuite/Core/Component/User/ActivityPointList.ts
@@ -7,22 +7,16 @@
* @since 6.2
*/
-import { dboAction } from "WoltLabSuite/Core/Ajax";
+import { getUserActivityPointList } from "WoltLabSuite/Core/Api/Users/GetUserActivityPointList";
import { promiseMutex } from "WoltLabSuite/Core/Helper/PromiseMutex";
import { wheneverFirstSeen } from "WoltLabSuite/Core/Helper/Selector";
import { dialogFactory } from "WoltLabSuite/Core/Component/Dialog";
import { getPhrase } from "WoltLabSuite/Core/Language";
-type ResponseGetDetailedActivityPointList = {
- template: string;
-};
-
async function showDialog(userId: number): Promise {
- const response = (await dboAction("getDetailedActivityPointList", "wcf\\data\\user\\UserProfileAction")
- .objectIds([userId])
- .dispatch()) as ResponseGetDetailedActivityPointList;
+ const { template } = await getUserActivityPointList(userId);
- const dialog = dialogFactory().fromHtml(response.template).withoutControls();
+ const dialog = dialogFactory().fromHtml(template).withoutControls();
dialog.show(getPhrase("wcf.user.activityPoint"));
}
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Users/GetUserActivityPointList.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Users/GetUserActivityPointList.js
new file mode 100644
index 00000000000..2ce990de314
--- /dev/null
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Users/GetUserActivityPointList.js
@@ -0,0 +1,19 @@
+/**
+ * Gets the html code for the rendering of the detailed activity point list of a user.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.3
+ */
+define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Core/Api/Result"], function (require, exports, Backend_1, Result_1) {
+ "use strict";
+ Object.defineProperty(exports, "__esModule", { value: true });
+ exports.getUserActivityPointList = getUserActivityPointList;
+ async function getUserActivityPointList(userId) {
+ const url = new URL(`${window.WSC_RPC_API_URL}core/users/${userId}/activity-points`);
+ return (0, Result_1.fromInfallibleApiRequest)(() => {
+ return (0, Backend_1.prepareRequest)(url).get().fetchAsJson();
+ });
+ }
+});
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/User/ActivityPointList.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/User/ActivityPointList.js
index 3f38e3f8333..267f943e4a7 100644
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/User/ActivityPointList.js
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/User/ActivityPointList.js
@@ -6,15 +6,13 @@
* @license GNU Lesser General Public License
* @since 6.2
*/
-define(["require", "exports", "WoltLabSuite/Core/Ajax", "WoltLabSuite/Core/Helper/PromiseMutex", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/Component/Dialog", "WoltLabSuite/Core/Language"], function (require, exports, Ajax_1, PromiseMutex_1, Selector_1, Dialog_1, Language_1) {
+define(["require", "exports", "WoltLabSuite/Core/Api/Users/GetUserActivityPointList", "WoltLabSuite/Core/Helper/PromiseMutex", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/Component/Dialog", "WoltLabSuite/Core/Language"], function (require, exports, GetUserActivityPointList_1, PromiseMutex_1, Selector_1, Dialog_1, Language_1) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.setup = setup;
async function showDialog(userId) {
- const response = (await (0, Ajax_1.dboAction)("getDetailedActivityPointList", "wcf\\data\\user\\UserProfileAction")
- .objectIds([userId])
- .dispatch());
- const dialog = (0, Dialog_1.dialogFactory)().fromHtml(response.template).withoutControls();
+ const { template } = await (0, GetUserActivityPointList_1.getUserActivityPointList)(userId);
+ const dialog = (0, Dialog_1.dialogFactory)().fromHtml(template).withoutControls();
dialog.show((0, Language_1.getPhrase)("wcf.user.activityPoint"));
}
function setup() {
diff --git a/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php b/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php
index 5c8fb306b1c..13fcca1c530 100644
--- a/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php
+++ b/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php
@@ -108,6 +108,7 @@ public function getMessagePreview()
*
* @return void
* @throws UserInputException
+ * @deprecated 6.3 use `wcf\system\endpoint\controller\core\users\GetUserActivityPointList` instead
*/
public function validateGetDetailedActivityPointList()
{
@@ -136,6 +137,7 @@ public function validateGetDetailedActivityPointList()
* Returns detailed activity point list.
*
* @return array{template: string, userID: int}
+ * @deprecated 6.3 use `wcf\system\endpoint\controller\core\users\GetUserActivityPointList` instead
*/
public function getDetailedActivityPointList()
{
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/GetUserActivityPointList.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/GetUserActivityPointList.class.php
new file mode 100644
index 00000000000..dbfb32b45e1
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/GetUserActivityPointList.class.php
@@ -0,0 +1,87 @@
+
+ * @since 6.3
+ */
+#[GetRequest('/core/users/{id:\d+}/activity-points')]
+final class GetUserActivityPointList implements IController
+{
+ #[\Override]
+ public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface
+ {
+ $user = Helper::fetchObjectFromRequestParameter($variables['id'], User::class);
+
+ $userProfile = UserProfileRuntimeCache::getInstance()->getObject($user->userID);
+ if (
+ $userProfile->userID !== WCF::getUser()->userID
+ && !WCF::getSession()->hasPermission('user.profile.canViewUserProfile')
+ ) {
+ throw new PermissionDeniedException();
+ }
+
+ if ($userProfile->isProtected()) {
+ throw new PermissionDeniedException();
+ }
+
+ return new JsonResponse([
+ 'template' => WCF::getTPL()->render('wcf', 'detailedActivityPointList', [
+ 'entries' => $this->getEntries($userProfile),
+ 'user' => $userProfile,
+ ]),
+ ]);
+ }
+
+ /**
+ * @return list
+ */
+ private function getEntries(UserProfile $user): array
+ {
+ $objectTypes = [];
+ foreach (ObjectTypeCache::getInstance()->getObjectTypes('com.woltlab.wcf.user.activityPointEvent') as $objectType) {
+ $objectTypes[$objectType->objectTypeID] = $objectType;
+ }
+
+ $conditionBuilder = new PreparedStatementConditionBuilder();
+ $conditionBuilder->add('userID = ?', [$user->userID]);
+ $conditionBuilder->add('objectTypeID IN (?)', [\array_keys($objectTypes)]);
+
+ $sql = "SELECT objectTypeID, activityPoints, items
+ FROM wcf1_user_activity_point
+ " . $conditionBuilder;
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute($conditionBuilder->getParameters());
+
+ $entries = [];
+ while ($row = $statement->fetchArray()) {
+ $entries[] = [
+ 'activityPoints' => $row['activityPoints'],
+ 'items' => $row['items'],
+ 'objectType' => $objectTypes[$row['objectTypeID']],
+ ];
+ }
+
+ return $entries;
+ }
+}
From f8fb914d3f39e6fd434dcbc3231af74e285dca71 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Sun, 11 Oct 2026 23:39:29 +0200
Subject: [PATCH 095/115] Extract special trophy update into SetSpecialTrophies
command
---
.../command/user/SetSpecialTrophies.class.php | 51 +++++++++++++++++++
.../lib/data/user/UserProfileAction.class.php | 31 ++---------
.../user/trophy/UserTrophyAction.class.php | 20 ++++----
.../files/lib/form/SettingsForm.class.php | 10 ++--
4 files changed, 70 insertions(+), 42 deletions(-)
create mode 100644 wcfsetup/install/files/lib/command/user/SetSpecialTrophies.class.php
diff --git a/wcfsetup/install/files/lib/command/user/SetSpecialTrophies.class.php b/wcfsetup/install/files/lib/command/user/SetSpecialTrophies.class.php
new file mode 100644
index 00000000000..680f01080fc
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/user/SetSpecialTrophies.class.php
@@ -0,0 +1,51 @@
+
+ * @since 6.3
+ */
+final class SetSpecialTrophies
+{
+ /**
+ * @param list $trophyIDs
+ */
+ public function __construct(
+ private readonly User $user,
+ private readonly array $trophyIDs,
+ ) {}
+
+ public function __invoke(): void
+ {
+ WCF::getDB()->beginTransaction();
+
+ $sql = "DELETE FROM wcf1_user_special_trophy
+ WHERE userID = ?";
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute([$this->user->userID]);
+
+ $sql = "INSERT INTO wcf1_user_special_trophy
+ (userID, trophyID)
+ VALUES (?, ?)";
+ $statement = WCF::getDB()->prepare($sql);
+ foreach ($this->trophyIDs as $trophyID) {
+ $statement->execute([
+ $this->user->userID,
+ $trophyID,
+ ]);
+ }
+
+ WCF::getDB()->commitTransaction();
+
+ UserStorageHandler::getInstance()->reset([$this->user->userID], 'specialTrophies');
+ }
+}
diff --git a/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php b/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php
index 13fcca1c530..e82506f1729 100644
--- a/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php
+++ b/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php
@@ -4,6 +4,7 @@
use wcf\command\user\RefreshUserOnlineMarkings;
use wcf\command\user\RefreshUserRanks;
+use wcf\command\user\SetSpecialTrophies;
use wcf\data\object\type\ObjectTypeCache;
use wcf\system\bbcode\BBCodeHandler;
use wcf\system\cache\runtime\UserProfileRuntimeCache;
@@ -14,7 +15,6 @@
use wcf\system\html\output\HtmlOutputProcessor;
use wcf\system\message\embedded\object\MessageEmbeddedObjectManager;
use wcf\system\upload\UploadFile;
-use wcf\system\user\storage\UserStorageHandler;
use wcf\system\WCF;
use wcf\util\ArrayUtil;
@@ -219,6 +219,7 @@ public function updateUserOnlineMarking()
* Updates the special trophies.
*
* @return void
+ * @deprecated 6.3 use the `SetSpecialTrophies` command instead.
*/
public function updateSpecialTrophies()
{
@@ -226,33 +227,9 @@ public function updateSpecialTrophies()
$this->readObjects();
}
- $sql = "DELETE FROM wcf1_user_special_trophy
- WHERE userID = ?";
- $deleteStatement = WCF::getDB()->prepare($sql);
-
- $sql = "INSERT INTO wcf1_user_special_trophy
- (userID, trophyID)
- VALUES (?, ?)";
- $insertStatement = WCF::getDB()->prepare($sql);
-
+ $trophyIDs = \array_values(\array_map(\intval(...), $this->parameters['trophyIDs'] ?? []));
foreach ($this->getObjects() as $user) {
- WCF::getDB()->beginTransaction();
-
- // delete all user special trophies for the user
- $deleteStatement->execute([$user->userID]);
-
- if (!empty($this->parameters['trophyIDs'])) {
- foreach ($this->parameters['trophyIDs'] as $trophyID) {
- $insertStatement->execute([
- $user->userID,
- $trophyID,
- ]);
- }
- }
-
- WCF::getDB()->commitTransaction();
-
- UserStorageHandler::getInstance()->reset([$user->userID], 'specialTrophies');
+ (new SetSpecialTrophies($user->getDecoratedObject(), $trophyIDs))();
}
}
}
diff --git a/wcfsetup/install/files/lib/data/user/trophy/UserTrophyAction.class.php b/wcfsetup/install/files/lib/data/user/trophy/UserTrophyAction.class.php
index c7abefa2927..90e3dc2af7f 100644
--- a/wcfsetup/install/files/lib/data/user/trophy/UserTrophyAction.class.php
+++ b/wcfsetup/install/files/lib/data/user/trophy/UserTrophyAction.class.php
@@ -2,10 +2,10 @@
namespace wcf\data\user\trophy;
+use wcf\command\user\SetSpecialTrophies;
use wcf\data\AbstractDatabaseObjectAction;
use wcf\data\user\UserAction;
use wcf\data\user\UserProfile;
-use wcf\data\user\UserProfileAction;
use wcf\system\cache\runtime\UserProfileRuntimeCache;
use wcf\system\database\util\PreparedStatementConditionBuilder;
use wcf\system\exception\IllegalLinkException;
@@ -68,16 +68,16 @@ public function create()
}
if (!$hasTrophy) {
- $userProfileAction = new UserProfileAction(
- [$userTrophy->getUserProfile()->getDecoratedObject()],
- 'updateSpecialTrophies',
- [
- 'trophyIDs' => \array_unique(\array_merge(\array_map(static function ($trophy) {
- return $trophy->trophyID;
- }, $userTrophy->getUserProfile()->getSpecialTrophies()), [$userTrophy->trophyID])),
- ]
+ $trophyIDs = \array_map(
+ static fn ($trophy) => $trophy->trophyID,
+ $userTrophy->getUserProfile()->getSpecialTrophies()
);
- $userProfileAction->executeAction();
+ $trophyIDs[] = $userTrophy->trophyID;
+
+ (new SetSpecialTrophies(
+ $userTrophy->getUserProfile()->getDecoratedObject(),
+ \array_values(\array_unique($trophyIDs))
+ ))();
}
}
}
diff --git a/wcfsetup/install/files/lib/form/SettingsForm.class.php b/wcfsetup/install/files/lib/form/SettingsForm.class.php
index 6f5d6c36e72..3a6708017fe 100644
--- a/wcfsetup/install/files/lib/form/SettingsForm.class.php
+++ b/wcfsetup/install/files/lib/form/SettingsForm.class.php
@@ -2,6 +2,7 @@
namespace wcf\form;
+use wcf\command\user\SetSpecialTrophies;
use wcf\command\user\UpdateUser;
use wcf\data\DatabaseObjectBuilder;
use wcf\data\language\Language;
@@ -14,7 +15,6 @@
use wcf\data\user\User;
use wcf\data\user\UserBuilder;
use wcf\data\user\UserProfile;
-use wcf\data\user\UserProfileAction;
use wcf\system\exception\IllegalLinkException;
use wcf\system\form\builder\container\FormContainer;
use wcf\system\form\builder\field\MultipleSelectionFormField;
@@ -309,10 +309,10 @@ protected function afterSave(): void
$specialTrophies = $this->form->getFormField('specialTrophies');
if ($specialTrophies !== null) {
- $userProfileAction = new UserProfileAction([$this->object->userID], 'updateSpecialTrophies', [
- 'trophyIDs' => \array_map(\intval(...), $specialTrophies->getValue() ?? []),
- ]);
- $userProfileAction->executeAction();
+ (new SetSpecialTrophies(
+ $this->object,
+ \array_values(\array_map(\intval(...), $specialTrophies->getValue() ?? []))
+ ))();
}
}
From fb613b4d49c94af827816c2d6b4825997f98de52 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Sun, 11 Oct 2026 23:45:57 +0200
Subject: [PATCH 096/115] Migrate signature preview to RPC endpoint
---
.../Core/Api/Users/RenderSignaturePreview.ts | 28 ++++++
.../Core/Component/User/Signature/Preview.ts | 34 ++++----
.../Core/Api/Users/RenderSignaturePreview.js | 24 +++++
.../Core/Component/User/Signature/Preview.js | 25 +++---
.../lib/data/user/UserProfileAction.class.php | 2 +
.../users/RenderSignaturePreview.class.php | 87 +++++++++++++++++++
6 files changed, 173 insertions(+), 27 deletions(-)
create mode 100644 ts/WoltLabSuite/Core/Api/Users/RenderSignaturePreview.ts
create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Api/Users/RenderSignaturePreview.js
create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/users/RenderSignaturePreview.class.php
diff --git a/ts/WoltLabSuite/Core/Api/Users/RenderSignaturePreview.ts b/ts/WoltLabSuite/Core/Api/Users/RenderSignaturePreview.ts
new file mode 100644
index 00000000000..e55ee62cf7e
--- /dev/null
+++ b/ts/WoltLabSuite/Core/Api/Users/RenderSignaturePreview.ts
@@ -0,0 +1,28 @@
+/**
+ * Renders the preview of the active user's signature.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.3
+ */
+
+import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend";
+import { ApiResult, apiResultFromError, apiResultFromValue } from "WoltLabSuite/Core/Api/Result";
+
+type Response = {
+ message: string;
+};
+
+export async function renderSignaturePreview(message: string): Promise> {
+ const url = new URL(`${window.WSC_RPC_API_URL}core/users/signature/preview`);
+
+ let response: Response;
+ try {
+ response = (await prepareRequest(url).post({ message }).fetchAsJson()) as Response;
+ } catch (e) {
+ return apiResultFromError(e);
+ }
+
+ return apiResultFromValue(response);
+}
diff --git a/ts/WoltLabSuite/Core/Component/User/Signature/Preview.ts b/ts/WoltLabSuite/Core/Component/User/Signature/Preview.ts
index a30e18b2bac..f266582847c 100644
--- a/ts/WoltLabSuite/Core/Component/User/Signature/Preview.ts
+++ b/ts/WoltLabSuite/Core/Component/User/Signature/Preview.ts
@@ -8,24 +8,26 @@
*/
import { promiseMutex } from "WoltLabSuite/Core/Helper/PromiseMutex";
-import { dboAction } from "WoltLabSuite/Core/Ajax";
+import { renderSignaturePreview } from "WoltLabSuite/Core/Api/Users/RenderSignaturePreview";
import { listenToCkeditor } from "WoltLabSuite/Core/Component/Ckeditor/Event";
-
-type ResponseGetMessagePreview = {
- message: string;
- raw: string;
-};
+import type { CKEditor } from "WoltLabSuite/Core/Component/Ckeditor";
+import { innerError } from "WoltLabSuite/Core/Dom/Util";
let previewContainer: HTMLElement;
-async function loadPreview(message: string): Promise {
- const response = (await dboAction("getMessagePreview", "wcf\\data\\user\\UserProfileAction")
- .payload({
- data: {
- message,
- },
- })
- .dispatch()) as ResponseGetMessagePreview;
+async function loadPreview(ckeditor: CKEditor): Promise {
+ const response = await renderSignaturePreview(ckeditor.getHtml());
+ if (!response.ok) {
+ const validationError = response.error.getValidationError();
+ if (validationError === undefined) {
+ throw new Error("Unexpected validation error", { cause: response.error });
+ }
+
+ innerError(ckeditor.element, validationError.code);
+ return;
+ }
+
+ innerError(ckeditor.element, false);
if (previewContainer === undefined) {
const template = document.getElementById("previewTemplate") as HTMLTemplateElement;
@@ -36,14 +38,14 @@ async function loadPreview(message: string): Promise {
previewContainer = document.getElementById("previewContainer")!;
}
- previewContainer.innerHTML = response.message;
+ previewContainer.innerHTML = response.value.message;
}
export function setup(): void {
listenToCkeditor(document.getElementById("text")!).ready(({ ckeditor }) => {
document.getElementById("previewButton")?.addEventListener(
"click",
- promiseMutex(() => loadPreview(ckeditor.getHtml())),
+ promiseMutex(() => loadPreview(ckeditor)),
);
});
}
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Users/RenderSignaturePreview.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Users/RenderSignaturePreview.js
new file mode 100644
index 00000000000..e4932295446
--- /dev/null
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Users/RenderSignaturePreview.js
@@ -0,0 +1,24 @@
+/**
+ * Renders the preview of the active user's signature.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.3
+ */
+define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Core/Api/Result"], function (require, exports, Backend_1, Result_1) {
+ "use strict";
+ Object.defineProperty(exports, "__esModule", { value: true });
+ exports.renderSignaturePreview = renderSignaturePreview;
+ async function renderSignaturePreview(message) {
+ const url = new URL(`${window.WSC_RPC_API_URL}core/users/signature/preview`);
+ let response;
+ try {
+ response = (await (0, Backend_1.prepareRequest)(url).post({ message }).fetchAsJson());
+ }
+ catch (e) {
+ return (0, Result_1.apiResultFromError)(e);
+ }
+ return (0, Result_1.apiResultFromValue)(response);
+ }
+});
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/User/Signature/Preview.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/User/Signature/Preview.js
index 360c3f76f2f..564d0a320e0 100644
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/User/Signature/Preview.js
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/User/Signature/Preview.js
@@ -6,19 +6,22 @@
* @license GNU Lesser General Public License
* @since 6.2
*/
-define(["require", "exports", "WoltLabSuite/Core/Helper/PromiseMutex", "WoltLabSuite/Core/Ajax", "WoltLabSuite/Core/Component/Ckeditor/Event"], function (require, exports, PromiseMutex_1, Ajax_1, Event_1) {
+define(["require", "exports", "WoltLabSuite/Core/Helper/PromiseMutex", "WoltLabSuite/Core/Api/Users/RenderSignaturePreview", "WoltLabSuite/Core/Component/Ckeditor/Event", "WoltLabSuite/Core/Dom/Util"], function (require, exports, PromiseMutex_1, RenderSignaturePreview_1, Event_1, Util_1) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.setup = setup;
let previewContainer;
- async function loadPreview(message) {
- const response = (await (0, Ajax_1.dboAction)("getMessagePreview", "wcf\\data\\user\\UserProfileAction")
- .payload({
- data: {
- message,
- },
- })
- .dispatch());
+ async function loadPreview(ckeditor) {
+ const response = await (0, RenderSignaturePreview_1.renderSignaturePreview)(ckeditor.getHtml());
+ if (!response.ok) {
+ const validationError = response.error.getValidationError();
+ if (validationError === undefined) {
+ throw new Error("Unexpected validation error", { cause: response.error });
+ }
+ (0, Util_1.innerError)(ckeditor.element, validationError.code);
+ return;
+ }
+ (0, Util_1.innerError)(ckeditor.element, false);
if (previewContainer === undefined) {
const template = document.getElementById("previewTemplate");
const fragment = template.content.cloneNode(true);
@@ -26,11 +29,11 @@ define(["require", "exports", "WoltLabSuite/Core/Helper/PromiseMutex", "WoltLabS
template.replaceWith(fragment);
previewContainer = document.getElementById("previewContainer");
}
- previewContainer.innerHTML = response.message;
+ previewContainer.innerHTML = response.value.message;
}
function setup() {
(0, Event_1.listenToCkeditor)(document.getElementById("text")).ready(({ ckeditor }) => {
- document.getElementById("previewButton")?.addEventListener("click", (0, PromiseMutex_1.promiseMutex)(() => loadPreview(ckeditor.getHtml())));
+ document.getElementById("previewButton")?.addEventListener("click", (0, PromiseMutex_1.promiseMutex)(() => loadPreview(ckeditor)));
});
}
});
diff --git a/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php b/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php
index e82506f1729..3076dcfd3e5 100644
--- a/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php
+++ b/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php
@@ -53,6 +53,7 @@ class UserProfileAction extends UserAction
* Validates parameters for signature preview.
*
* @return void
+ * @deprecated 6.3 use `wcf\system\endpoint\controller\core\users\RenderSignaturePreview` instead
*/
public function validateGetMessagePreview()
{
@@ -64,6 +65,7 @@ public function validateGetMessagePreview()
*
* @return array{message: string, raw: string}
* @throws UserInputException
+ * @deprecated 6.3 use `wcf\system\endpoint\controller\core\users\RenderSignaturePreview` instead
*/
public function getMessagePreview()
{
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/RenderSignaturePreview.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/RenderSignaturePreview.class.php
new file mode 100644
index 00000000000..f9145d48822
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/RenderSignaturePreview.class.php
@@ -0,0 +1,87 @@
+
+ * @since 6.3
+ */
+#[PostRequest('/core/users/signature/preview')]
+final class RenderSignaturePreview implements IController
+{
+ #[\Override]
+ public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface
+ {
+ if (
+ WCF::getUser()->isGuest()
+ || WCF::getUser()->disableSignature !== 0
+ || !WCF::getSession()->hasPermission('user.signature.canEditSignature')
+ ) {
+ throw new PermissionDeniedException();
+ }
+
+ $parameters = Helper::mapApiParameters($request, RenderSignaturePreviewParameters::class);
+
+ BBCodeHandler::getInstance()->setDisallowedBBCodes(ArrayUtil::trim(\explode(
+ ',',
+ WCF::getSession()->getPermission('user.signature.disallowedBBCodes')
+ )));
+
+ $htmlInputProcessor = new HtmlInputProcessor();
+ $htmlInputProcessor->process(
+ $parameters->message,
+ 'com.woltlab.wcf.user.signature',
+ WCF::getUser()->userID
+ );
+
+ $disallowedBBCodes = $htmlInputProcessor->validate();
+ if ($disallowedBBCodes !== []) {
+ throw new UserInputException(
+ 'message',
+ WCF::getLanguage()->getDynamicVariable('wcf.message.error.disallowedBBCodes', [
+ 'disallowedBBCodes' => $disallowedBBCodes,
+ ])
+ );
+ }
+
+ MessageEmbeddedObjectManager::getInstance()->registerTemporaryMessage($htmlInputProcessor);
+
+ $htmlOutputProcessor = new HtmlOutputProcessor();
+ $htmlOutputProcessor->process(
+ $htmlInputProcessor->getHtml(),
+ 'com.woltlab.wcf.user.signature',
+ WCF::getUser()->userID
+ );
+
+ return new JsonResponse([
+ 'message' => $htmlOutputProcessor->getHtml(),
+ ]);
+ }
+}
+
+/** @internal */
+final class RenderSignaturePreviewParameters
+{
+ public function __construct(
+ public readonly string $message,
+ ) {}
+}
From 7aa7e5833a1eba28495982b2de1b27187ebcb47a Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Sun, 11 Oct 2026 23:56:31 +0200
Subject: [PATCH 097/115] Deprecate `UserProfileAction`
---
.../files/lib/data/user/UserAction.class.php | 28 +++++++++++--------
.../lib/data/user/UserProfileAction.class.php | 1 +
2 files changed, 17 insertions(+), 12 deletions(-)
diff --git a/wcfsetup/install/files/lib/data/user/UserAction.class.php b/wcfsetup/install/files/lib/data/user/UserAction.class.php
index ec74a54ff51..52f84d31184 100644
--- a/wcfsetup/install/files/lib/data/user/UserAction.class.php
+++ b/wcfsetup/install/files/lib/data/user/UserAction.class.php
@@ -7,6 +7,8 @@
use wcf\command\user\ConfirmUserEmail;
use wcf\command\user\DisableUser;
use wcf\command\user\EnableUser;
+use wcf\command\user\RefreshUserOnlineMarkings;
+use wcf\command\user\RefreshUserRanks;
use wcf\command\user\ResendActivationMail;
use wcf\command\user\UnbanUser;
use wcf\command\user\UnconfirmUserEmail;
@@ -284,12 +286,10 @@ public function create()
// update user rank
if (\MODULE_USER_RANK !== 0) {
- $action = new UserProfileAction([$userEditor], 'updateUserRank');
- $action->executeAction();
+ (new RefreshUserRanks([$user]))();
}
// update user online marking
- $action = new UserProfileAction([$userEditor], 'updateUserOnlineMarking');
- $action->executeAction();
+ (new RefreshUserOnlineMarkings([$user]))();
}
return $user;
@@ -434,13 +434,15 @@ public function removeFromGroups()
UserEditor::resetCache();
$this->readObjects();
+ $users = \array_values(\array_map(
+ static fn (UserEditor $editor) => $editor->getDecoratedObject(),
+ $this->getObjects()
+ ));
if (\MODULE_USER_RANK !== 0) {
- $action = new UserProfileAction($this->objects, 'updateUserRank');
- $action->executeAction();
+ (new RefreshUserRanks($users))();
}
if (\MODULE_USERS_ONLINE !== 0) {
- $action = new UserProfileAction($this->objects, 'updateUserOnlineMarking');
- $action->executeAction();
+ (new RefreshUserOnlineMarkings($users))();
}
}
@@ -479,13 +481,15 @@ public function addToGroups()
UserEditor::resetCache();
$this->readObjects();
+ $users = \array_values(\array_map(
+ static fn (UserEditor $editor) => $editor->getDecoratedObject(),
+ $this->getObjects()
+ ));
if (\MODULE_USER_RANK !== 0) {
- $action = new UserProfileAction($this->objects, 'updateUserRank');
- $action->executeAction();
+ (new RefreshUserRanks($users))();
}
if (\MODULE_USERS_ONLINE !== 0) {
- $action = new UserProfileAction($this->objects, 'updateUserOnlineMarking');
- $action->executeAction();
+ (new RefreshUserOnlineMarkings($users))();
}
}
diff --git a/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php b/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php
index 3076dcfd3e5..99513e043cf 100644
--- a/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php
+++ b/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php
@@ -24,6 +24,7 @@
* @author Alexander Ebert
* @copyright 2001-2019 WoltLab GmbH
* @license GNU Lesser General Public License
+ * @deprecated 6.3 Use `UserBuilder` and commands instead.
*/
class UserProfileAction extends UserAction
{
From de49ba6829ba690ef4fa4b87c7e70428ebe93d90 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Mon, 12 Oct 2026 00:01:23 +0200
Subject: [PATCH 098/115] Remove obsolete `UserContentAction`
---
com.woltlab.wcf/fileDelete.xml | 1 +
.../lib/data/user/UserContentAction.class.php | 41 -------------------
2 files changed, 1 insertion(+), 41 deletions(-)
delete mode 100644 wcfsetup/install/files/lib/data/user/UserContentAction.class.php
diff --git a/com.woltlab.wcf/fileDelete.xml b/com.woltlab.wcf/fileDelete.xml
index 2f838089c41..f0144d2b08f 100644
--- a/com.woltlab.wcf/fileDelete.xml
+++ b/com.woltlab.wcf/fileDelete.xml
@@ -27,6 +27,7 @@
js/WoltLabSuite/Core/Ui/User/Editor.js
lib/acp/action/UserQuickSearchAction.class.php
lib/acp/form/UserSearchForm.class.php
+ lib/data/user/UserContentAction.class.php
lib/system/clipboard/action/UserClipboardAction.class.php
lib/system/clipboard/action/UserContentClipboardAction.class.php
lib/system/listView/user/UserSearchResultsListView.class.php
diff --git a/wcfsetup/install/files/lib/data/user/UserContentAction.class.php b/wcfsetup/install/files/lib/data/user/UserContentAction.class.php
deleted file mode 100644
index 6b448f54b76..00000000000
--- a/wcfsetup/install/files/lib/data/user/UserContentAction.class.php
+++ /dev/null
@@ -1,41 +0,0 @@
-
- */
-class UserContentAction extends UserAction
-{
- /**
- * Checks permissions to bulk revert.
- *
- * @return void
- */
- public function validateBulkRevert()
- {
- WCF::getSession()->checkPermissions(['admin.content.canBulkRevertContentChanges']);
- }
-
- /**
- * Bulk reverts changes made to history saving objects.
- *
- * @return void
- */
- public function bulkRevert()
- {
- $this->readInteger('timeframe', true);
- if ($this->parameters['timeframe'] === 0) {
- $this->parameters['timeframe'] = 86400 * 7;
- }
-
- EditHistoryManager::getInstance()->bulkRevert($this->objectIDs, $this->parameters['timeframe']);
- }
-}
From b175c808435304bdb45e40d3443e759154974c85 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Mon, 12 Oct 2026 00:07:09 +0200
Subject: [PATCH 099/115] Remove obsolete `UserRegistrationAction` and legacy
registration validation JS
---
com.woltlab.wcf/fileDelete.xml | 1 +
wcfsetup/install/files/js/WCF.User.js | 303 ------------------
.../user/UserRegistrationAction.class.php | 92 ------
3 files changed, 1 insertion(+), 395 deletions(-)
delete mode 100644 wcfsetup/install/files/lib/data/user/UserRegistrationAction.class.php
diff --git a/com.woltlab.wcf/fileDelete.xml b/com.woltlab.wcf/fileDelete.xml
index f0144d2b08f..0059cf6e82a 100644
--- a/com.woltlab.wcf/fileDelete.xml
+++ b/com.woltlab.wcf/fileDelete.xml
@@ -28,6 +28,7 @@
lib/acp/action/UserQuickSearchAction.class.php
lib/acp/form/UserSearchForm.class.php
lib/data/user/UserContentAction.class.php
+ lib/data/user/UserRegistrationAction.class.php
lib/system/clipboard/action/UserClipboardAction.class.php
lib/system/clipboard/action/UserContentClipboardAction.class.php
lib/system/listView/user/UserSearchResultsListView.class.php
diff --git a/wcfsetup/install/files/js/WCF.User.js b/wcfsetup/install/files/js/WCF.User.js
index b7193d85d19..23dc5bdb878 100644
--- a/wcfsetup/install/files/js/WCF.User.js
+++ b/wcfsetup/install/files/js/WCF.User.js
@@ -250,309 +250,6 @@ WCF.User.Profile.TabMenu = Class.extend({
}
});
-/**
- * Namespace for registration functions.
- */
-WCF.User.Registration = {};
-
-/**
- * Validates the password.
- *
- * @param jQuery element
- * @param jQuery confirmElement
- * @param object options
- * @deprecated 6.1 use `WoltLabSuite/Core/Controller/User/Registration` instead
- */
-WCF.User.Registration.Validation = Class.extend({
- /**
- * action name
- * @var string
- */
- _actionName: '',
-
- /**
- * class name
- * @var string
- */
- _className: '',
-
- /**
- * confirmation input element
- * @var jQuery
- */
- _confirmElement: null,
-
- /**
- * input element
- * @var jQuery
- */
- _element: null,
-
- /**
- * list of error messages
- * @var object
- */
- _errorMessages: { },
-
- /**
- * list of additional options
- * @var object
- */
- _options: { },
-
- /**
- * AJAX proxy
- * @var WCF.Action.Proxy
- */
- _proxy: null,
-
- /**
- * Initializes the validation.
- *
- * @param jQuery element
- * @param jQuery confirmElement
- * @param object options
- */
- init: function(element, confirmElement, options) {
- this._element = element;
- this._element.blur($.proxy(this._blur, this));
- this._confirmElement = confirmElement || null;
-
- if (this._confirmElement !== null) {
- this._confirmElement.blur($.proxy(this._blurConfirm, this));
- }
-
- options = options || { };
- this._setOptions(options);
-
- this._proxy = new WCF.Action.Proxy({
- success: $.proxy(this._success, this),
- showLoadingOverlay: false
- });
-
- this._setErrorMessages();
- },
-
- /**
- * Sets additional options
- */
- _setOptions: function(options) { },
-
- /**
- * Sets error messages.
- */
- _setErrorMessages: function() {
- this._errorMessages = {
- ajaxError: '',
- notEqual: ''
- };
- },
-
- /**
- * Validates once focus on input is lost.
- *
- * @param object event
- */
- _blur: function(event) {
- var $value = this._element.val();
- if (!$value) {
- return this._showError(this._element, WCF.Language.get('wcf.global.form.error.empty'));
- }
-
- if (this._confirmElement !== null) {
- var $confirmValue = this._confirmElement.val();
- if ($confirmValue != '' && $value != $confirmValue) {
- return this._showError(this._confirmElement, this._errorMessages.notEqual);
- }
- }
-
- if (!this._validateOptions()) {
- return;
- }
-
- this._proxy.setOption('data', {
- actionName: this._actionName,
- className: this._className,
- parameters: this._getParameters()
- });
- this._proxy.sendRequest();
- },
-
- /**
- * Returns a list of parameters.
- *
- * @return object
- */
- _getParameters: function() {
- return { };
- },
-
- /**
- * Validates input by options.
- *
- * @return boolean
- */
- _validateOptions: function() {
- return true;
- },
-
- /**
- * Validates value once confirmation input focus is lost.
- *
- * @param object event
- */
- _blurConfirm: function(event) {
- var $value = this._confirmElement.val();
- if (!$value) {
- return this._showError(this._confirmElement, WCF.Language.get('wcf.global.form.error.empty'));
- }
-
- this._blur(event);
- },
-
- /**
- * Handles AJAX responses.
- *
- * @param object data
- * @param string textStatus
- * @param jQuery jqXHR
- */
- _success: function(data, textStatus, jqXHR) {
- if (data.returnValues.isValid) {
- this._showSuccess(this._element);
- if (this._confirmElement !== null && this._confirmElement.val()) {
- this._showSuccess(this._confirmElement);
- }
- }
- else {
- this._showError(this._element, WCF.Language.get(this._errorMessages.ajaxError + data.returnValues.error));
- }
- },
-
- /**
- * Shows an error message.
- *
- * @param jQuery element
- * @param string message
- */
- _showError: function(element, message) {
- element.parent().parent().addClass('formError').removeClass('formSuccess');
-
- var $innerError = element.parent().find('small.innerError');
- if (!$innerError.length) {
- $innerError = $(' ').addClass('innerError').insertAfter(element);
- }
-
- $innerError.text(message);
- },
-
- /**
- * Displays a success message.
- *
- * @param jQuery element
- */
- _showSuccess: function(element) {
- element.parent().parent().addClass('formSuccess').removeClass('formError');
- element.next('small.innerError').remove();
- }
-});
-
-/**
- * Username validation for registration.
- *
- * @see WCF.User.Registration.Validation
- * @deprecated 6.1 use `WoltLabSuite/Core/Controller/User/Registration` instead
- */
-WCF.User.Registration.Validation.Username = WCF.User.Registration.Validation.extend({
- /**
- * @see WCF.User.Registration.Validation._actionName
- */
- _actionName: 'validateUsername',
-
- /**
- * @see WCF.User.Registration.Validation._className
- */
- _className: 'wcf\\data\\user\\UserRegistrationAction',
-
- /**
- * @see WCF.User.Registration.Validation._setOptions()
- */
- _setOptions: function(options) {
- this._options = $.extend(true, {
- minlength: 3,
- maxlength: 25
- }, options);
- },
-
- /**
- * @see WCF.User.Registration.Validation._setErrorMessages()
- */
- _setErrorMessages: function() {
- this._errorMessages = {
- ajaxError: 'wcf.user.username.error.'
- };
- },
-
- /**
- * @see WCF.User.Registration.Validation._validateOptions()
- */
- _validateOptions: function() {
- var $value = this._element.val();
- if ($value.length < this._options.minlength || $value.length > this._options.maxlength) {
- this._showError(this._element, WCF.Language.get('wcf.user.username.error.invalid'));
- return false;
- }
-
- return true;
- },
-
- /**
- * @see WCF.User.Registration.Validation._getParameters()
- */
- _getParameters: function() {
- return {
- username: this._element.val()
- };
- }
-});
-
-/**
- * Email validation for registration.
- *
- * @see WCF.User.Registration.Validation
- * @deprecated 6.1 use `WoltLabSuite/Core/Controller/User/Registration` instead
- */
-WCF.User.Registration.Validation.EmailAddress = WCF.User.Registration.Validation.extend({
- /**
- * @see WCF.User.Registration.Validation._actionName
- */
- _actionName: 'validateEmailAddress',
-
- /**
- * @see WCF.User.Registration.Validation._className
- */
- _className: 'wcf\\data\\user\\UserRegistrationAction',
-
- /**
- * @see WCF.User.Registration.Validation._getParameters()
- */
- _getParameters: function() {
- return {
- email: this._element.val()
- };
- },
-
- /**
- * @see WCF.User.Registration.Validation._setErrorMessages()
- */
- _setErrorMessages: function() {
- this._errorMessages = {
- ajaxError: 'wcf.user.email.error.',
- notEqual: WCF.Language.get('wcf.user.confirmEmail.error.notEqual')
- };
- }
-});
-
/**
* Notification system for WCF.
*
diff --git a/wcfsetup/install/files/lib/data/user/UserRegistrationAction.class.php b/wcfsetup/install/files/lib/data/user/UserRegistrationAction.class.php
deleted file mode 100644
index 8c968f2fa78..00000000000
--- a/wcfsetup/install/files/lib/data/user/UserRegistrationAction.class.php
+++ /dev/null
@@ -1,92 +0,0 @@
-
- */
-class UserRegistrationAction extends UserAction
-{
- /**
- * @inheritDoc
- */
- protected $allowGuestAccess = ['validateEmailAddress', 'validateUsername'];
-
- /**
- * Validates the validate username function.
- *
- * @return void
- */
- public function validateValidateUsername()
- {
- $this->readString('username');
- }
-
- /**
- * Validates the validate email address function.
- *
- * @return void
- */
- public function validateValidateEmailAddress()
- {
- $this->readString('email');
- }
-
- /**
- * Validates the given username.
- *
- * @return array{isValid: bool, error?: string}
- */
- public function validateUsername()
- {
- if (!UserRegistrationUtil::isValidUsername($this->parameters['username'])) {
- return [
- 'isValid' => false,
- 'error' => 'invalid',
- ];
- }
-
- if (!User::getUserByUsername($this->parameters['username'])->isGuest()) {
- return [
- 'isValid' => false,
- 'error' => 'notUnique',
- ];
- }
-
- return [
- 'isValid' => true,
- ];
- }
-
- /**
- * Validates given email address.
- *
- * @return array{isValid: bool, error?: string}
- */
- public function validateEmailAddress()
- {
- if (!UserRegistrationUtil::isValidEmail($this->parameters['email'])) {
- return [
- 'isValid' => false,
- 'error' => 'invalid',
- ];
- }
-
- if (!User::getUserByEmail($this->parameters['email'])->isGuest()) {
- return [
- 'isValid' => false,
- 'error' => 'notUnique',
- ];
- }
-
- return [
- 'isValid' => true,
- ];
- }
-}
From c8a1b47837d4cddae6b0dabd76a7c9138f8f59de Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Mon, 12 Oct 2026 00:27:06 +0200
Subject: [PATCH 100/115] Deprecate legacy `UserAction` methods
---
.../files/lib/data/user/UserAction.class.php | 52 +++++++++++++++++++
.../lib/form/EmailActivationForm.class.php | 4 +-
.../lib/form/RegisterActivationForm.class.php | 9 ++--
3 files changed, 58 insertions(+), 7 deletions(-)
diff --git a/wcfsetup/install/files/lib/data/user/UserAction.class.php b/wcfsetup/install/files/lib/data/user/UserAction.class.php
index 52f84d31184..f19a27efeae 100644
--- a/wcfsetup/install/files/lib/data/user/UserAction.class.php
+++ b/wcfsetup/install/files/lib/data/user/UserAction.class.php
@@ -183,6 +183,8 @@ public function validateUpdate()
* Validates the ban action.
*
* @return void
+ *
+ * @deprecated 6.3
*/
public function validateBan()
{
@@ -196,6 +198,8 @@ public function validateBan()
* Validates the unban action.
*
* @return void
+ *
+ * @deprecated 6.3
*/
public function validateUnban()
{
@@ -208,6 +212,8 @@ public function validateUnban()
* Bans users.
*
* @return string
+ *
+ * @deprecated 6.3 Use the `BanUser` command instead.
*/
public function ban()
{
@@ -238,6 +244,8 @@ public function ban()
* Unbans users.
*
* @return void
+ *
+ * @deprecated 6.3 Use the `UnbanUser` command instead.
*/
public function unban()
{
@@ -635,6 +643,8 @@ protected function unmarkItems(array $userIDs = [])
* Validates the enable action.
*
* @return void
+ *
+ * @deprecated 6.3
*/
public function validateEnable()
{
@@ -647,6 +657,8 @@ public function validateEnable()
* Validates the disable action.
*
* @return void
+ *
+ * @deprecated 6.3
*/
public function validateDisable()
{
@@ -658,6 +670,8 @@ public function validateDisable()
*
* @return void
* @since 5.3
+ *
+ * @deprecated 6.3
*/
public function validateConfirmEmail()
{
@@ -669,6 +683,8 @@ public function validateConfirmEmail()
*
* @return void
* @since 5.3
+ *
+ * @deprecated 6.3
*/
public function validateUnconfirmEmail()
{
@@ -680,6 +696,8 @@ public function validateUnconfirmEmail()
*
* @return void
* @since 5.3
+ *
+ * @deprecated 6.3 Use the `ConfirmUserEmail` command instead.
*/
public function confirmEmail()
{
@@ -698,6 +716,8 @@ public function confirmEmail()
*
* @return void
* @since 5.3
+ *
+ * @deprecated 6.3 Use the `UnconfirmUserEmail` command instead.
*/
public function unconfirmEmail()
{
@@ -714,6 +734,8 @@ public function unconfirmEmail()
* Enables users.
*
* @return void
+ *
+ * @deprecated 6.3 Use the `EnableUser` command instead.
*/
public function enable()
{
@@ -731,6 +753,8 @@ public function enable()
* Disables users.
*
* @return void
+ *
+ * @deprecated 6.3 Use the `DisableUser` command instead.
*/
public function disable()
{
@@ -771,6 +795,8 @@ protected function readObjects()
* Validates the 'disableSignature' action.
*
* @return void
+ *
+ * @deprecated 6.3
*/
public function validateDisableSignature()
{
@@ -784,6 +810,8 @@ public function validateDisableSignature()
* Disables the signature of the handled users.
*
* @return void
+ *
+ * @deprecated 6.3 Use the `DisableUserSignature` command instead.
*/
public function disableSignature()
{
@@ -811,6 +839,8 @@ public function disableSignature()
* Validates the 'enableSignature' action.
*
* @return void
+ *
+ * @deprecated 6.3
*/
public function validateEnableSignature()
{
@@ -831,6 +861,8 @@ public function validateEnableSignature()
* Enables the signature of the handled users.
*
* @return void
+ *
+ * @deprecated 6.3 Use the `EnableUserSignature` command instead.
*/
public function enableSignature()
{
@@ -849,6 +881,8 @@ public function enableSignature()
* Validates the 'disableAvatar' action.
*
* @return void
+ *
+ * @deprecated 6.3
*/
public function validateDisableAvatar()
{
@@ -862,6 +896,8 @@ public function validateDisableAvatar()
* Disables the avatar of the handled users.
*
* @return void
+ *
+ * @deprecated 6.3 Use the `DisableUserAvatar` command instead.
*/
public function disableAvatar()
{
@@ -890,6 +926,8 @@ public function disableAvatar()
*
* @return void
* @since 5.2
+ *
+ * @deprecated 6.3
*/
public function validateDisableCoverPhoto()
{
@@ -904,6 +942,8 @@ public function validateDisableCoverPhoto()
*
* @return void
* @since 5.2
+ *
+ * @deprecated 6.3 Use the `DisableUserCoverPhoto` command instead.
*/
public function disableCoverPhoto()
{
@@ -931,6 +971,8 @@ public function disableCoverPhoto()
* Validates the 'enableAvatar' action.
*
* @return void
+ *
+ * @deprecated 6.3
*/
public function validateEnableAvatar()
{
@@ -951,6 +993,8 @@ public function validateEnableAvatar()
* Enables the avatar of the handled users.
*
* @return void
+ *
+ * @deprecated 6.3 Use the `EnableUserAvatar` command instead.
*/
public function enableAvatar()
{
@@ -970,6 +1014,8 @@ public function enableAvatar()
*
* @return void
* @since 5.2
+ *
+ * @deprecated 6.3
*/
public function validateEnableCoverPhoto()
{
@@ -991,6 +1037,8 @@ public function validateEnableCoverPhoto()
*
* @return void
* @since 5.2
+ *
+ * @deprecated 6.3 Use the `EnableUserCoverPhoto` command instead.
*/
public function enableCoverPhoto()
{
@@ -1031,6 +1079,8 @@ public function saveUserConsent()
* @throws PermissionDeniedException
* @throws UserInputException
* @since 5.2
+ *
+ * @deprecated 6.3
*/
public function validateResendActivationMail()
{
@@ -1056,6 +1106,8 @@ public function validateResendActivationMail()
*
* @return void
* @since 5.2
+ *
+ * @deprecated 6.3 Use the `ResendActivationMail` command instead.
*/
public function resendActivationMail()
{
diff --git a/wcfsetup/install/files/lib/form/EmailActivationForm.class.php b/wcfsetup/install/files/lib/form/EmailActivationForm.class.php
index 2a969b5fb1b..f39dc6d59b1 100644
--- a/wcfsetup/install/files/lib/form/EmailActivationForm.class.php
+++ b/wcfsetup/install/files/lib/form/EmailActivationForm.class.php
@@ -2,6 +2,7 @@
namespace wcf\form;
+use wcf\command\user\ConfirmUserEmail;
use wcf\data\user\User;
use wcf\data\user\UserAction;
use wcf\system\exception\IllegalLinkException;
@@ -143,8 +144,7 @@ public function save()
// confirm email
if (!$this->user->isEmailConfirmed() && $this->user->blacklistMatches === '') {
- $this->objectAction = new UserAction([$this->user], 'confirmEmail');
- $this->objectAction->executeAction();
+ (new ConfirmUserEmail($this->user))();
}
$this->saved();
diff --git a/wcfsetup/install/files/lib/form/RegisterActivationForm.class.php b/wcfsetup/install/files/lib/form/RegisterActivationForm.class.php
index c8c4ad2ec5a..fa2970d0b8c 100644
--- a/wcfsetup/install/files/lib/form/RegisterActivationForm.class.php
+++ b/wcfsetup/install/files/lib/form/RegisterActivationForm.class.php
@@ -2,8 +2,9 @@
namespace wcf\form;
+use wcf\command\user\ConfirmUserEmail;
+use wcf\command\user\CreateRegistrationNotification;
use wcf\data\user\User;
-use wcf\data\user\UserAction;
use wcf\system\exception\IllegalLinkException;
use wcf\system\exception\NamedUserException;
use wcf\system\exception\PermissionDeniedException;
@@ -139,8 +140,7 @@ public function save()
{
AbstractForm::save();
- $this->objectAction = new UserAction([$this->user], 'confirmEmail', ['skipNotification' => true]);
- $this->objectAction->executeAction();
+ $user = (new ConfirmUserEmail($this->user, false))();
$this->saved();
// forward to index page
@@ -151,8 +151,7 @@ public function save()
$redirectText = WCF::getLanguage()->getDynamicVariable('wcf.user.registerActivation.success');
}
- // User must be reloaded to get the correct activation status.
- new \wcf\command\user\CreateRegistrationNotification(new User($this->user->userID))();
+ (new CreateRegistrationNotification($user))();
HeaderUtil::delayedRedirect(LinkHandler::getInstance()->getLink(), $redirectText, 10, 'success', true);
From 325e89c576d0fd5413e0d5e19e3f552c81eb69eb Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Mon, 12 Oct 2026 11:56:22 +0200
Subject: [PATCH 101/115] Migrate user deletion callers to `DeleteUser` command
---
.../user/DeleteUserBulkProcessingAction.class.php | 7 +++----
.../files/lib/system/cronjob/UserQuitCronjob.class.php | 8 ++++----
.../endpoint/controller/core/users/DeleteUser.class.php | 5 +----
3 files changed, 8 insertions(+), 12 deletions(-)
diff --git a/wcfsetup/install/files/lib/system/bulk/processing/user/DeleteUserBulkProcessingAction.class.php b/wcfsetup/install/files/lib/system/bulk/processing/user/DeleteUserBulkProcessingAction.class.php
index 49704a9fa1f..292f2998a79 100644
--- a/wcfsetup/install/files/lib/system/bulk/processing/user/DeleteUserBulkProcessingAction.class.php
+++ b/wcfsetup/install/files/lib/system/bulk/processing/user/DeleteUserBulkProcessingAction.class.php
@@ -2,8 +2,8 @@
namespace wcf\system\bulk\processing\user;
+use wcf\command\user\DeleteUser;
use wcf\data\DatabaseObjectList;
-use wcf\data\user\UserAction;
use wcf\system\WCF;
/**
@@ -20,9 +20,8 @@ public function executeAction(DatabaseObjectList $objectList)
{
$users = $this->getAccessibleUsers($objectList);
- if ($users !== []) {
- $userAction = new UserAction($users, 'delete');
- $userAction->executeAction();
+ foreach ($users as $user) {
+ new DeleteUser($user)();
}
}
diff --git a/wcfsetup/install/files/lib/system/cronjob/UserQuitCronjob.class.php b/wcfsetup/install/files/lib/system/cronjob/UserQuitCronjob.class.php
index c98710e6dc6..2a1b8586f42 100644
--- a/wcfsetup/install/files/lib/system/cronjob/UserQuitCronjob.class.php
+++ b/wcfsetup/install/files/lib/system/cronjob/UserQuitCronjob.class.php
@@ -2,8 +2,9 @@
namespace wcf\system\cronjob;
+use wcf\command\user\DeleteUser;
use wcf\data\cronjob\Cronjob;
-use wcf\data\user\UserAction;
+use wcf\system\cache\runtime\UserRuntimeCache;
use wcf\system\WCF;
/**
@@ -31,9 +32,8 @@ public function execute(Cronjob $cronjob)
]);
$userIDs = $statement->fetchAll(\PDO::FETCH_COLUMN);
- if ($userIDs !== []) {
- $action = new UserAction($userIDs, 'delete');
- $action->executeAction();
+ foreach (UserRuntimeCache::getInstance()->getObjects($userIDs) as $user) {
+ new DeleteUser($user)();
}
}
}
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/DeleteUser.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/DeleteUser.class.php
index 5c621132839..97d56fc367c 100644
--- a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/DeleteUser.class.php
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/DeleteUser.class.php
@@ -6,7 +6,6 @@
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use wcf\data\user\User;
-use wcf\data\user\UserAction;
use wcf\http\Helper;
use wcf\system\endpoint\DeleteRequest;
use wcf\system\endpoint\IController;
@@ -32,9 +31,7 @@ public function __invoke(ServerRequestInterface $request, array $variables): Res
throw new PermissionDeniedException();
}
- // Plugins clean up their data through the events of `UserAction`.
- $action = new UserAction([$user], 'delete');
- $action->executeAction();
+ new \wcf\command\user\DeleteUser($user)();
return new JsonResponse([]);
}
From 7c7b966276d5a0efe12992f4c45d24a5bad991dd Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Mon, 12 Oct 2026 11:59:13 +0200
Subject: [PATCH 102/115] Deprecate `UserAction::delete()` in favor of
`DeleteUser` command
---
.../install/files/lib/data/user/UserAction.class.php | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/wcfsetup/install/files/lib/data/user/UserAction.class.php b/wcfsetup/install/files/lib/data/user/UserAction.class.php
index f19a27efeae..fb17148b8cd 100644
--- a/wcfsetup/install/files/lib/data/user/UserAction.class.php
+++ b/wcfsetup/install/files/lib/data/user/UserAction.class.php
@@ -126,6 +126,8 @@ protected function __validateAccessibleGroups(bool $ignoreOwnUser = true)
/**
* Validates permissions and parameters.
+ *
+ * @deprecated 6.3
*/
#[\Override]
public function validateDelete()
@@ -136,6 +138,9 @@ public function validateDelete()
$this->__validateAccessibleGroups();
}
+ /**
+ * @deprecated 6.3 Use the `DeleteUser` command instead.
+ */
#[\Override]
public function delete()
{
@@ -443,7 +448,7 @@ public function removeFromGroups()
$this->readObjects();
$users = \array_values(\array_map(
- static fn (UserEditor $editor) => $editor->getDecoratedObject(),
+ static fn(UserEditor $editor) => $editor->getDecoratedObject(),
$this->getObjects()
));
if (\MODULE_USER_RANK !== 0) {
@@ -490,7 +495,7 @@ public function addToGroups()
$this->readObjects();
$users = \array_values(\array_map(
- static fn (UserEditor $editor) => $editor->getDecoratedObject(),
+ static fn(UserEditor $editor) => $editor->getDecoratedObject(),
$this->getObjects()
));
if (\MODULE_USER_RANK !== 0) {
From 4091946b0c4ecc6e9e702a5e2521d1f0fd5b2459 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Mon, 12 Oct 2026 12:03:27 +0200
Subject: [PATCH 103/115] Replace `cancelLostPasswordRequest` action with
`UserBuilder` in login listener
---
wcfsetup/install/files/lib/data/user/UserAction.class.php | 1 +
.../listener/UserLoginCancelLostPasswordListener.class.php | 7 +++++--
2 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/wcfsetup/install/files/lib/data/user/UserAction.class.php b/wcfsetup/install/files/lib/data/user/UserAction.class.php
index fb17148b8cd..563d67f153d 100644
--- a/wcfsetup/install/files/lib/data/user/UserAction.class.php
+++ b/wcfsetup/install/files/lib/data/user/UserAction.class.php
@@ -1151,6 +1151,7 @@ public function devtoolsSetLanguage()
/**
* @since 5.5
+ * @deprecated 6.3
*/
public function cancelLostPasswordRequest(): void
{
diff --git a/wcfsetup/install/files/lib/system/event/listener/UserLoginCancelLostPasswordListener.class.php b/wcfsetup/install/files/lib/system/event/listener/UserLoginCancelLostPasswordListener.class.php
index b271fe03441..9876ab45e82 100644
--- a/wcfsetup/install/files/lib/system/event/listener/UserLoginCancelLostPasswordListener.class.php
+++ b/wcfsetup/install/files/lib/system/event/listener/UserLoginCancelLostPasswordListener.class.php
@@ -2,7 +2,7 @@
namespace wcf\system\event\listener;
-use wcf\data\user\UserAction;
+use wcf\data\user\UserBuilder;
use wcf\event\user\authentication\UserLoggedIn;
/**
@@ -22,6 +22,9 @@ public function __invoke(UserLoggedIn $event): void
return;
}
- (new UserAction([$user], 'cancelLostPasswordRequest'))->executeAction();
+ UserBuilder::forUpdate($user)
+ ->setLostPasswordKey('')
+ ->setLastLostPasswordRequestTime(0)
+ ->update();
}
}
From a898840241da0d7e6a909ca36bdc4dce2efe65ec Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Mon, 12 Oct 2026 12:11:38 +0200
Subject: [PATCH 104/115] Add `RemoveUserFromGroups` command and deprecate
`UserAction::removeFromGroups()`
---
.../RemoveGroupMembership.class.php | 6 +--
.../user/RemoveUserFromGroups.class.php | 39 +++++++++++++++++++
.../files/lib/data/user/UserAction.class.php | 26 ++++---------
3 files changed, 48 insertions(+), 23 deletions(-)
create mode 100644 wcfsetup/install/files/lib/command/user/RemoveUserFromGroups.class.php
diff --git a/wcfsetup/install/files/lib/command/paid/subscription/RemoveGroupMembership.class.php b/wcfsetup/install/files/lib/command/paid/subscription/RemoveGroupMembership.class.php
index a379abe6e66..e34622b1c3e 100644
--- a/wcfsetup/install/files/lib/command/paid/subscription/RemoveGroupMembership.class.php
+++ b/wcfsetup/install/files/lib/command/paid/subscription/RemoveGroupMembership.class.php
@@ -2,10 +2,10 @@
namespace wcf\command\paid\subscription;
+use wcf\command\user\RemoveUserFromGroups;
use wcf\data\paid\subscription\PaidSubscription;
use wcf\data\user\group\UserGroup;
use wcf\data\user\User;
-use wcf\data\user\UserAction;
/**
* Removes user groups from a user based on the paid subscription's associated group IDs.
@@ -30,9 +30,7 @@ public function __invoke(): void
return;
}
- (new UserAction([$this->user], 'removeFromGroups', [
- 'groups' => $groupIDs,
- ]))->executeAction();
+ (new RemoveUserFromGroups($this->user, $groupIDs))();
}
/**
diff --git a/wcfsetup/install/files/lib/command/user/RemoveUserFromGroups.class.php b/wcfsetup/install/files/lib/command/user/RemoveUserFromGroups.class.php
new file mode 100644
index 00000000000..d2bae8225ea
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/user/RemoveUserFromGroups.class.php
@@ -0,0 +1,39 @@
+
+ * @since 6.3
+ */
+final class RemoveUserFromGroups
+{
+ /**
+ * @param list $groupIDs
+ */
+ public function __construct(
+ private readonly User $user,
+ private readonly array $groupIDs,
+ ) {}
+
+ public function __invoke(): void
+ {
+ $currentGroupIDs = $this->user->getGroupIDs(true);
+ $groupIDs = \array_values(\array_diff($currentGroupIDs, $this->groupIDs));
+
+ if (\count($groupIDs) === \count($currentGroupIDs)) {
+ return;
+ }
+
+ new UpdateUser(
+ UserBuilder::forUpdate($this->user)->setGroupIDs($groupIDs)
+ )();
+ }
+}
diff --git a/wcfsetup/install/files/lib/data/user/UserAction.class.php b/wcfsetup/install/files/lib/data/user/UserAction.class.php
index 563d67f153d..eaa33769982 100644
--- a/wcfsetup/install/files/lib/data/user/UserAction.class.php
+++ b/wcfsetup/install/files/lib/data/user/UserAction.class.php
@@ -9,6 +9,7 @@
use wcf\command\user\EnableUser;
use wcf\command\user\RefreshUserOnlineMarkings;
use wcf\command\user\RefreshUserRanks;
+use wcf\command\user\RemoveUserFromGroups;
use wcf\command\user\ResendActivationMail;
use wcf\command\user\UnbanUser;
use wcf\command\user\UnconfirmUserEmail;
@@ -429,6 +430,8 @@ public function update()
* Remove users from given groups.
*
* @return void
+ *
+ * @deprecated 6.3 Use the `RemoveUserFromGroups` command instead.
*/
public function removeFromGroups()
{
@@ -436,26 +439,11 @@ public function removeFromGroups()
$this->readObjects();
}
- $groupIDs = $this->parameters['groups'];
-
foreach ($this->getObjects() as $userEditor) {
- $userEditor->removeFromGroups($groupIDs);
- }
-
- //reread objects
- $this->objects = [];
- UserEditor::resetCache();
- $this->readObjects();
-
- $users = \array_values(\array_map(
- static fn(UserEditor $editor) => $editor->getDecoratedObject(),
- $this->getObjects()
- ));
- if (\MODULE_USER_RANK !== 0) {
- (new RefreshUserRanks($users))();
- }
- if (\MODULE_USERS_ONLINE !== 0) {
- (new RefreshUserOnlineMarkings($users))();
+ (new RemoveUserFromGroups(
+ $userEditor->getDecoratedObject(),
+ \array_values(\array_map(static fn($groupID) => (int)$groupID, $this->parameters['groups']))
+ ))();
}
}
From 2ffffd1ed93a6c7f86384ff48daca4619f442198 Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Mon, 12 Oct 2026 12:33:15 +0200
Subject: [PATCH 105/115] Add `AddUserToGroups` command and deprecate
`UserAction::addToGroups()`
---
phpstan-baseline.neon | 2 +-
.../action/UserAssignToGroupAction.class.php | 14 ++---
.../subscription/AddGroupMembership.class.php | 8 +--
.../command/user/AddUserToGroups.class.php | 41 ++++++++++++++
.../lib/command/user/UpdateUser.class.php | 5 +-
.../files/lib/data/user/UserAction.class.php | 55 ++++++++-----------
.../UserGroupAssignmentCronjob.class.php | 12 +---
.../UserGroupAssignmentHandler.class.php | 10 +---
8 files changed, 83 insertions(+), 64 deletions(-)
create mode 100644 wcfsetup/install/files/lib/command/user/AddUserToGroups.class.php
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
index 93101f357c0..71c13aae820 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -622,7 +622,7 @@ parameters:
-
message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
identifier: empty.notAllowed
- count: 9
+ count: 8
path: wcfsetup/install/files/lib/data/user/UserAction.class.php
-
diff --git a/wcfsetup/install/files/lib/acp/action/UserAssignToGroupAction.class.php b/wcfsetup/install/files/lib/acp/action/UserAssignToGroupAction.class.php
index 3db01df5dc0..1bc37c40b14 100644
--- a/wcfsetup/install/files/lib/acp/action/UserAssignToGroupAction.class.php
+++ b/wcfsetup/install/files/lib/acp/action/UserAssignToGroupAction.class.php
@@ -7,9 +7,9 @@
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
+use wcf\command\user\AddUserToGroups;
use wcf\data\user\group\UserGroup;
use wcf\data\user\User;
-use wcf\data\user\UserAction;
use wcf\data\user\UserList;
use wcf\http\Helper;
use wcf\system\exception\IllegalLinkException;
@@ -43,16 +43,14 @@ public function handle(ServerRequestInterface $request): ResponseInterface
return $response;
}
- $groupIDs = \array_map(
+ $groupIDs = \array_values(\array_map(
static fn($groupID) => (int)$groupID,
$form->getData()['data']['groupIDs']
- );
+ ));
- (new UserAction(\array_values($users), 'addToGroups', [
- 'groups' => $groupIDs,
- 'deleteOldGroups' => false,
- 'addDefaultGroups' => false,
- ]))->executeAction();
+ foreach ($users as $user) {
+ (new AddUserToGroups($user, $groupIDs))();
+ }
return new JsonResponse([]);
} else {
diff --git a/wcfsetup/install/files/lib/command/paid/subscription/AddGroupMembership.class.php b/wcfsetup/install/files/lib/command/paid/subscription/AddGroupMembership.class.php
index 25e50a45f8a..da7de21dc02 100644
--- a/wcfsetup/install/files/lib/command/paid/subscription/AddGroupMembership.class.php
+++ b/wcfsetup/install/files/lib/command/paid/subscription/AddGroupMembership.class.php
@@ -2,10 +2,10 @@
namespace wcf\command\paid\subscription;
+use wcf\command\user\AddUserToGroups;
use wcf\data\paid\subscription\PaidSubscription;
use wcf\data\user\group\UserGroup;
use wcf\data\user\User;
-use wcf\data\user\UserAction;
/**
* Adds user groups to a user based on the paid subscription's associated group IDs.
@@ -30,11 +30,7 @@ public function __invoke(): void
return;
}
- (new UserAction([$this->user], 'addToGroups', [
- 'groups' => $groupIDs,
- 'deleteOldGroups' => false,
- 'addDefaultGroups' => false,
- ]))->executeAction();
+ (new AddUserToGroups($this->user, $groupIDs))();
}
/**
diff --git a/wcfsetup/install/files/lib/command/user/AddUserToGroups.class.php b/wcfsetup/install/files/lib/command/user/AddUserToGroups.class.php
new file mode 100644
index 00000000000..13098a0e3de
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/user/AddUserToGroups.class.php
@@ -0,0 +1,41 @@
+
+ * @since 6.3
+ */
+final class AddUserToGroups
+{
+ /**
+ * @param list $groupIDs
+ */
+ public function __construct(
+ private readonly User $user,
+ private readonly array $groupIDs,
+ private readonly bool $ignoreUserGroupAssignments = false,
+ ) {}
+
+ public function __invoke(): void
+ {
+ $currentGroupIDs = $this->user->getGroupIDs(true);
+ $groupIDs = \array_values(\array_unique(\array_merge($currentGroupIDs, $this->groupIDs)));
+
+ if (\count($groupIDs) === \count($currentGroupIDs)) {
+ return;
+ }
+
+ new UpdateUser(
+ UserBuilder::forUpdate($this->user)->setGroupIDs($groupIDs),
+ $this->ignoreUserGroupAssignments
+ )();
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/user/UpdateUser.class.php b/wcfsetup/install/files/lib/command/user/UpdateUser.class.php
index b4e744f1318..aaf20946255 100644
--- a/wcfsetup/install/files/lib/command/user/UpdateUser.class.php
+++ b/wcfsetup/install/files/lib/command/user/UpdateUser.class.php
@@ -25,6 +25,7 @@ final class UpdateUser
{
public function __construct(
private readonly UserBuilder $builder,
+ private readonly bool $ignoreUserGroupAssignments = false,
) {}
public function __invoke(): User
@@ -62,7 +63,9 @@ public function __invoke(): User
// The automatic group assignments and the user rank can depend on user options.
if ($groupsChanged || $this->builder->getUserOptions() !== null) {
- UserGroupAssignmentHandler::getInstance()->checkUsers([$user->userID]);
+ if (!$this->ignoreUserGroupAssignments) {
+ UserGroupAssignmentHandler::getInstance()->checkUsers([$user->userID]);
+ }
if (\MODULE_USER_RANK !== 0) {
(new RefreshUserRanks([$user]))();
diff --git a/wcfsetup/install/files/lib/data/user/UserAction.class.php b/wcfsetup/install/files/lib/data/user/UserAction.class.php
index eaa33769982..e1aac22aa24 100644
--- a/wcfsetup/install/files/lib/data/user/UserAction.class.php
+++ b/wcfsetup/install/files/lib/data/user/UserAction.class.php
@@ -3,6 +3,7 @@
namespace wcf\data\user;
use wcf\command\file\DeleteFiles;
+use wcf\command\user\AddUserToGroups;
use wcf\command\user\BanUser;
use wcf\command\user\ConfirmUserEmail;
use wcf\command\user\DisableUser;
@@ -13,6 +14,7 @@
use wcf\command\user\ResendActivationMail;
use wcf\command\user\UnbanUser;
use wcf\command\user\UnconfirmUserEmail;
+use wcf\command\user\UpdateUser;
use wcf\data\AbstractDatabaseObjectAction;
use wcf\data\file\File;
use wcf\data\ISearchAction;
@@ -27,7 +29,6 @@
use wcf\system\language\LanguageFactory;
use wcf\system\session\SessionHandler;
use wcf\system\style\FontAwesomeIcon;
-use wcf\system\user\group\assignment\UserGroupAssignmentHandler;
use wcf\system\WCF;
/**
@@ -451,6 +452,8 @@ public function removeFromGroups()
* Add users to given groups.
*
* @return void
+ *
+ * @deprecated 6.3 Use the `AddUserToGroups` command instead.
*/
public function addToGroups()
{
@@ -458,39 +461,29 @@ public function addToGroups()
$this->readObjects();
}
- $groupIDs = $this->parameters['groups'];
- $deleteOldGroups = $addDefaultGroups = true;
- if (isset($this->parameters['deleteOldGroups'])) {
- $deleteOldGroups = $this->parameters['deleteOldGroups'];
- }
- if (isset($this->parameters['addDefaultGroups'])) {
- $addDefaultGroups = $this->parameters['addDefaultGroups'];
- }
-
- $userIDs = [];
- foreach ($this->getObjects() as $userEditor) {
- $userIDs[] = $userEditor->userID;
- $userEditor->addToGroups($groupIDs, $deleteOldGroups, $addDefaultGroups);
- }
+ $deleteOldGroups = (bool)($this->parameters['deleteOldGroups'] ?? true);
+ $addDefaultGroups = (bool)($this->parameters['addDefaultGroups'] ?? true);
+ $ignoreUserGroupAssignments = (bool)($this->parameters['ignoreUserGroupAssignments'] ?? false);
- if (empty($this->parameters['ignoreUserGroupAssignments'])) {
- UserGroupAssignmentHandler::getInstance()->checkUsers($userIDs);
+ $groupIDs = \array_map(static fn($groupID) => (int)$groupID, $this->parameters['groups']);
+ if ($addDefaultGroups) {
+ $groupIDs = \array_merge($groupIDs, UserGroup::getGroupIDsByType([UserGroup::EVERYONE, UserGroup::USERS]));
}
+ $groupIDs = \array_values(\array_unique($groupIDs));
- // reread objects
- $this->objects = [];
- UserEditor::resetCache();
- $this->readObjects();
-
- $users = \array_values(\array_map(
- static fn(UserEditor $editor) => $editor->getDecoratedObject(),
- $this->getObjects()
- ));
- if (\MODULE_USER_RANK !== 0) {
- (new RefreshUserRanks($users))();
- }
- if (\MODULE_USERS_ONLINE !== 0) {
- (new RefreshUserOnlineMarkings($users))();
+ foreach ($this->getObjects() as $userEditor) {
+ if ($deleteOldGroups) {
+ (new UpdateUser(
+ UserBuilder::forUpdate($userEditor->getDecoratedObject())->setGroupIDs($groupIDs),
+ $ignoreUserGroupAssignments
+ ))();
+ } else {
+ (new AddUserToGroups(
+ $userEditor->getDecoratedObject(),
+ $groupIDs,
+ $ignoreUserGroupAssignments
+ ))();
+ }
}
}
diff --git a/wcfsetup/install/files/lib/system/cronjob/UserGroupAssignmentCronjob.class.php b/wcfsetup/install/files/lib/system/cronjob/UserGroupAssignmentCronjob.class.php
index 4c819325633..d9256ccb5e6 100644
--- a/wcfsetup/install/files/lib/system/cronjob/UserGroupAssignmentCronjob.class.php
+++ b/wcfsetup/install/files/lib/system/cronjob/UserGroupAssignmentCronjob.class.php
@@ -2,8 +2,8 @@
namespace wcf\system\cronjob;
+use wcf\command\user\AddUserToGroups;
use wcf\data\cronjob\Cronjob;
-use wcf\data\user\UserAction;
use wcf\system\cache\builder\UserGroupAssignmentCacheBuilder;
use wcf\system\user\group\assignment\UserGroupAssignmentHandler;
@@ -42,14 +42,8 @@ public function execute(Cronjob $cronjob)
}
foreach ($usersToGroup as $groupID => $users) {
- if ($users !== []) {
- $userAction = new UserAction(\array_unique($users), 'addToGroups', [
- 'addDefaultGroups' => false,
- 'deleteOldGroups' => false,
- 'groups' => [$groupID],
- 'ignoreUserGroupAssignments' => true,
- ]);
- $userAction->executeAction();
+ foreach ($users as $user) {
+ (new AddUserToGroups($user, [$groupID], ignoreUserGroupAssignments: true))();
}
}
}
diff --git a/wcfsetup/install/files/lib/system/user/group/assignment/UserGroupAssignmentHandler.class.php b/wcfsetup/install/files/lib/system/user/group/assignment/UserGroupAssignmentHandler.class.php
index 681fcb49c83..5dfa3a23348 100644
--- a/wcfsetup/install/files/lib/system/user/group/assignment/UserGroupAssignmentHandler.class.php
+++ b/wcfsetup/install/files/lib/system/user/group/assignment/UserGroupAssignmentHandler.class.php
@@ -2,11 +2,11 @@
namespace wcf\system\user\group\assignment;
+use wcf\command\user\AddUserToGroups;
use wcf\data\object\type\ObjectType;
use wcf\data\object\type\ObjectTypeCache;
use wcf\data\user\group\assignment\UserGroupAssignment;
use wcf\data\user\User;
-use wcf\data\user\UserAction;
use wcf\data\user\UserList;
use wcf\system\cache\builder\UserGroupAssignmentCacheBuilder;
use wcf\system\object\filter\builder\UserGroupAssignmentObjectFilterBuilder;
@@ -69,13 +69,7 @@ public function checkUsers(array $userIDs)
}
if ($newGroupIDs !== []) {
- $userAction = new UserAction([$user], 'addToGroups', [
- 'addDefaultGroups' => false,
- 'deleteOldGroups' => false,
- 'groups' => $newGroupIDs,
- 'ignoreUserGroupAssignments' => true,
- ]);
- $userAction->executeAction();
+ (new AddUserToGroups($user, $newGroupIDs, ignoreUserGroupAssignments: true))();
}
}
}
From 92bd18845df060577746e037a2ea13308f8084ee Mon Sep 17 00:00:00 2001
From: Marcel Werk
Date: Mon, 12 Oct 2026 12:46:34 +0200
Subject: [PATCH 106/115] Replace `devtoolsSetLanguage` action with
`SetLanguage` RPC endpoint
---
.../templates/__devtoolsLanguageChooser.tpl | 15 +----
.../Core/Api/Devtools/SetLanguage.ts | 22 +++++++
.../Core/Component/LanguageSwitcher.ts | 6 +-
.../templates/__devtoolsLanguageChooser.tpl | 15 +----
.../Core/Api/Devtools/SetLanguage.js | 23 +++++++
.../Core/Component/LanguageSwitcher.js | 7 +--
.../files/lib/data/user/UserAction.class.php | 2 +
.../core/devtools/SetLanguage.class.php | 62 +++++++++++++++++++
8 files changed, 120 insertions(+), 32 deletions(-)
create mode 100644 ts/WoltLabSuite/Core/Api/Devtools/SetLanguage.ts
create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Api/Devtools/SetLanguage.js
create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/devtools/SetLanguage.class.php
diff --git a/com.woltlab.wcf/templates/__devtoolsLanguageChooser.tpl b/com.woltlab.wcf/templates/__devtoolsLanguageChooser.tpl
index 9183c08f0ed..baa2c105c09 100644
--- a/com.woltlab.wcf/templates/__devtoolsLanguageChooser.tpl
+++ b/com.woltlab.wcf/templates/__devtoolsLanguageChooser.tpl
@@ -1,7 +1,7 @@
{* The `system_pageHeader` renders this chooser on the server. *}
{if ENABLE_DEBUG_MODE && ENABLE_DEVELOPER_TOOLS && !$__wcf->user->isGuest() && $__wcf->getStyleHandler()->getStyle()->usesClassicPageHeader()}