Date: Fri, 4 Sep 2026 09:19:18 +0100
Subject: [PATCH 2/7] refactor: Load language lines via the Translation service
Replaces `get_instance()->lang->load(...)` with `Factory::service('Translation')->load(...)`, which works outside a CodeIgniter request.
Co-Authored-By: Claude Fable 5.1
(cherry picked from commit 63bca4ff7bccc3c4cf7c35278ad941e60cc74a77)
---
src/Admin/Controller/Accounts.php | 2 +-
src/Controller/Base.php | 2 +-
src/Event/Listener/User/Init.php | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/Admin/Controller/Accounts.php b/src/Admin/Controller/Accounts.php
index 0f07710e..ad38f570 100755
--- a/src/Admin/Controller/Accounts.php
+++ b/src/Admin/Controller/Accounts.php
@@ -274,7 +274,7 @@ public function __construct()
// --------------------------------------------------------------------------
- get_instance()->lang->load('admin_accounts');
+ Factory::service('Translation')->load('admin_accounts');
/** @var ChangeLog oChangeLogModel */
$this->oChangeLogModel = Factory::model('ChangeLog', \Nails\Admin\Constants::MODULE_SLUG);
}
diff --git a/src/Controller/Base.php b/src/Controller/Base.php
index f39deef9..b73d809b 100644
--- a/src/Controller/Base.php
+++ b/src/Controller/Base.php
@@ -28,7 +28,7 @@ public function __construct()
parent::__construct();
$oConfig = Factory::service('Config');
$oConfig->load('auth/auth');
- get_instance()->lang->load('auth/auth');
+ Factory::service('Translation')->load('auth');
}
// --------------------------------------------------------------------------
diff --git a/src/Event/Listener/User/Init.php b/src/Event/Listener/User/Init.php
index 99540a71..873f9601 100644
--- a/src/Event/Listener/User/Init.php
+++ b/src/Event/Listener/User/Init.php
@@ -151,7 +151,7 @@ protected function checkUserIsSuspended(): self
/** @var Authentication $oAuthService */
$oAuthService = Factory::service('Authentication', Constants::MODULE_SLUG);
- get_instance()->lang->load('auth/auth');
+ Factory::service('Translation')->load('auth');
$oAuthService->logout();
From 48222e4f534315b3bcfc0692a57bcd15b9b21a10 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pablo=20de=20la=20Pen=CC=83a?=
Date: Fri, 4 Sep 2026 09:19:18 +0100
Subject: [PATCH 3/7] refactor: Read cross-field values via the validation
`Context` in import rules
The password rule read `group_id` through the FormValidation service's CodeIgniter `validation_data` property; it now receives a `Context` and reads the row being validated directly, which also removes the last reason the rules needed the service at all.
Co-Authored-By: Claude Fable 5.1
(cherry picked from commit 3a8dd87ad51f274761ec02e194dcc8cab6f245d0)
---
src/Service/User/Import.php | 7 +++----
1 file changed, 3 insertions(+), 4 deletions(-)
diff --git a/src/Service/User/Import.php b/src/Service/User/Import.php
index 1767ea05..52486bad 100644
--- a/src/Service/User/Import.php
+++ b/src/Service/User/Import.php
@@ -21,6 +21,7 @@
use Nails\Common\Factory\Model\Field;
use Nails\Common\Service\DateTime;
use Nails\Common\Service\FormValidation;
+use Nails\Common\Validation\Context;
use Nails\Config;
use Nails\Factory;
@@ -85,8 +86,6 @@ public function getValidationRules(string $key): array
$oUserPasswordModel = Factory::model('UserPassword', Constants::MODULE_SLUG);
/** @var DateTime $oDateTimeService */
$oDateTimeService = Factory::service('DateTime');
- /** @var FormValidation $oFormValidationService */
- $oFormValidationService = Factory::service('FormValidation');
return match ($key) {
'email' => array_filter([
@@ -138,13 +137,13 @@ function ($groupId) use ($oUserGroupModel) {
},
],
'password' => [
- function ($password) use ($oUserPasswordModel, $oUserGroupModel, $oFormValidationService) {
+ function ($password, Context $oContext) use ($oUserPasswordModel, $oUserGroupModel) {
if (!$password) {
return;
}
- $groupId = $oFormValidationService->validation_data['group_id'] ?? null;
+ $groupId = $oContext->getValue('group_id');
$group = $oUserGroupModel->getById((int) $groupId) ?? $oUserGroupModel->getDefaultGroup();
if (!$oUserPasswordModel->isAcceptable($group, $password)) {
From fe2cb24132dc48c3eb530e9352efe5de961579f7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pablo=20de=20la=20Pen=CC=83a?=
Date: Fri, 4 Sep 2026 09:45:51 +0100
Subject: [PATCH 4/7] feat: Add `Identity` and `Identifier` validators and use
them across the auth forms
`Nails\Auth\Validator\User\Identity` owns the email/username rules (required, format, length, uniqueness, per `APP_NATIVE_LOGIN_USING`) and the "already registered" message; `Identifier` owns the login identifier rule. Registration, social sign-on data capture, admin account creation, login and forgotten-password previously each rebuilt these rules with small differences; they now extend the shared validators via `addRules()`/`setRules()`. Both classes are unit tested with the uniqueness rule stubbed, so no database is needed.
Behaviour notes: registration and social sign-on now also enforce `alpha_dash_period` and `max_length[150]` on usernames (previously admin-only); admin account creation's "already registered" message now carries the forgotten-password link and uses the `auth_register_*_is_unique` lines rather than `fv_*_already_registered`.
Co-Authored-By: Claude Fable 5.1
(cherry picked from commit 7a47ee8084961fd6959fb26dce8c0b063740e7ff)
---
auth/controllers/Login.php | 56 +++-------
auth/controllers/PasswordForgotten.php | 16 +--
auth/controllers/Register.php | 44 ++------
src/Admin/Controller/Accounts.php | 37 ++-----
src/Validator/User/Identifier.php | 34 +++++++
src/Validator/User/Identity.php | 129 ++++++++++++++++++++++++
tests/Validator/User/IdentifierTest.php | 37 +++++++
tests/Validator/User/IdentityTest.php | 123 ++++++++++++++++++++++
8 files changed, 354 insertions(+), 122 deletions(-)
create mode 100644 src/Validator/User/Identifier.php
create mode 100644 src/Validator/User/Identity.php
create mode 100644 tests/Validator/User/IdentifierTest.php
create mode 100644 tests/Validator/User/IdentityTest.php
diff --git a/auth/controllers/Login.php b/auth/controllers/Login.php
index d91f7de6..225e53e2 100644
--- a/auth/controllers/Login.php
+++ b/auth/controllers/Login.php
@@ -22,6 +22,8 @@
use Nails\Auth\Resource;
use Nails\Auth\Service\Authentication;
use Nails\Auth\Service\SocialSignOn;
+use Nails\Auth\Validator\User\Identifier;
+use Nails\Auth\Validator\User\Identity;
use Nails\Cdn\Service\Cdn;
use Nails\Common\Exception\FactoryException;
use Nails\Common\Exception\ValidationException;
@@ -113,8 +115,6 @@ public function index()
$oInput = Factory::service('Input');
/** @var \App\Auth\Model\User $oUserModel */
$oUserModel = Factory::model('User', Constants::MODULE_SLUG);
- /** @var FormValidation $oFormValidation */
- $oFormValidation = Factory::service('FormValidation');
/** @var Authentication $oAuthService */
$oAuthService = Factory::service('Authentication', Constants::MODULE_SLUG);
/** @var SocialSignOn $oSocial */
@@ -128,20 +128,9 @@ public function index()
try {
- $oFormValidation
- ->buildValidator([
- 'identifier' => array_values(array_filter([
- \Nails\Config::get('APP_NATIVE_LOGIN_USING') === 'EMAIL' ? [
- $oFormValidation::RULE_REQUIRED,
- $oFormValidation::RULE_VALID_EMAIL,
- ] : null,
- \Nails\Config::get('APP_NATIVE_LOGIN_USING') === 'USERNAME' ? [$oFormValidation::RULE_REQUIRED] : null,
- \Nails\Config::get('APP_NATIVE_LOGIN_USING') === 'BOTH' ? [$oFormValidation::RULE_REQUIRED] : null,
- ]))[0],
- 'password' => [$oFormValidation::RULE_REQUIRED],
- 'remember' => [],
- ])
- ->run();
+ (new Identifier())
+ ->addRules(['password' => [FormValidation::RULE_REQUIRED]])
+ ->run($oInput->post());
if (appSetting('user_login_captcha_enabled', 'auth')) {
if (!$oCaptchaService->verify()) {
@@ -945,26 +934,15 @@ protected function socialSignOnRequestData(array &$aRequiredData): void
$oInput = Factory::service('Input');
if ($oInput->post()) {
- /** @var FormValidation $oFormValidation */
- $oFormValidation = Factory::service('FormValidation');
-
+ // Only capture the identity fields we've been asked for
$aRules = [];
- if (isset($aRequiredData['email'])) {
- $aRules['email'] = [
- 'trim',
- FormValidation::RULE_REQUIRED,
- FormValidation::RULE_VALID_EMAIL,
- FormValidation::rule(FormValidation::RULE_IS_UNIQUE, \Nails\Config::get('NAILS_DB_PREFIX') . 'user_email', 'email'),
- ];
+ if (!isset($aRequiredData['email'])) {
+ $aRules['email'] = [];
}
- if (isset($aRequiredData['username'])) {
- $aRules['username'] = [
- 'trim',
- FormValidation::RULE_REQUIRED,
- FormValidation::rule(FormValidation::RULE_IS_UNIQUE, \Nails\Config::get('NAILS_DB_PREFIX') . 'user', 'username'),
- ];
+ if (!isset($aRequiredData['username'])) {
+ $aRules['username'] = [];
}
if (empty($aRequiredData['first_name'])) {
@@ -975,20 +953,12 @@ protected function socialSignOnRequestData(array &$aRequiredData): void
$aRules['last_name'] = ['trim', FormValidation::RULE_REQUIRED];
}
- $sIsUniqueMessage = match (\Nails\Config::get('APP_NATIVE_LOGIN_USING')) {
- 'EMAIL' => lang('fv_email_already_registered', siteUrl('auth/password/forgotten')),
- 'USERNAME' => lang('fv_username_already_registered', siteUrl('auth/password/forgotten')),
- default => lang('fv_identity_already_registered', siteUrl('auth/password/forgotten')),
- };
-
try {
- $oValidator = $oFormValidation
- ->buildValidator($aRules, [
- FormValidation::RULE_IS_UNIQUE => $sIsUniqueMessage,
- ])
+ $oValidator = (new Identity())
+ ->setRules($aRules)
->setLabels(['email' => 'email', 'username' => 'username'])
- ->run();
+ ->run($oInput->post());
// Valid! Ensure required data is set correctly then allow system to move on.
$aPost = $oValidator->getValidatedData();
diff --git a/auth/controllers/PasswordForgotten.php b/auth/controllers/PasswordForgotten.php
index 58eed802..e41c79a2 100644
--- a/auth/controllers/PasswordForgotten.php
+++ b/auth/controllers/PasswordForgotten.php
@@ -17,12 +17,12 @@
use Nails\Auth\Model\User;
use Nails\Auth\Model\User\Password;
use Nails\Auth\Service\Authentication;
+use Nails\Auth\Validator\User\Identifier;
use Nails\Common\Exception\Encrypt\DecodeException;
use Nails\Common\Exception\EnvironmentException;
use Nails\Common\Exception\FactoryException;
use Nails\Common\Exception\NailsException;
use Nails\Common\Service\Config;
-use Nails\Common\Service\FormValidation;
use Nails\Common\Service\Input;
use Nails\Common\Service\Uri;
use Nails\Factory;
@@ -53,8 +53,6 @@ public function index()
{
/** @var Input $oInput */
$oInput = Factory::service('Input');
- /** @var FormValidation $oFormValidation */
- $oFormValidation = Factory::service('FormValidation');
/** @var Config $oConfig */
$oConfig = Factory::service('Config');
/** @var Password $oUserPasswordModel */
@@ -86,17 +84,7 @@ public function index()
// --------------------------------------------------------------------------
- $aRules = array_filter([
- \Nails\Config::get('APP_NATIVE_LOGIN_USING') === 'EMAIL' ? ['required', 'valid_email'] : null,
- \Nails\Config::get('APP_NATIVE_LOGIN_USING') === 'USERNAME' ? ['required'] : null,
- \Nails\Config::get('APP_NATIVE_LOGIN_USING') === 'BOTH' ? ['required'] : null,
- ]);
-
- $oFormValidation
- ->buildValidator([
- 'identifier' => reset($aRules),
- ])
- ->run();
+ (new Identifier())->run($oInput->post());
if (appSetting('user_password_reset_captcha_enabled', 'auth')) {
if (!$oCaptchaService->verify()) {
diff --git a/auth/controllers/Register.php b/auth/controllers/Register.php
index d5432b8e..8054a061 100644
--- a/auth/controllers/Register.php
+++ b/auth/controllers/Register.php
@@ -15,6 +15,7 @@
use Nails\Auth\Model\User\Group;
use Nails\Auth\Model\User\Password;
use Nails\Auth\Service\SocialSignOn;
+use Nails\Auth\Validator\User\Identity;
use Nails\Common\Service\FormValidation;
use Nails\Common\Service\Input;
use Nails\Common\Service\Session;
@@ -57,8 +58,6 @@ public function index()
{
/** @var Session $oSession */
$oSession = Factory::service('Session');
- /** @var FormValidation $oFormValidation */
- $oFormValidation = Factory::service('FormValidation');
/** @var Input $oInput */
$oInput = Factory::service('Input');
/** @var \Nails\Auth\Model\User $oUserModel */
@@ -88,42 +87,13 @@ public function index()
try {
- $oFormValidation
- ->buildValidator([
- 'first_name' => [$oFormValidation::RULE_REQUIRED],
- 'last_name' => [$oFormValidation::RULE_REQUIRED],
- 'password' => [$oFormValidation::RULE_REQUIRED],
- 'email' => in_array(Config::get('APP_NATIVE_LOGIN_USING'), ['EMAIL', 'BOTH']) ? [
- $oFormValidation::RULE_REQUIRED,
- $oFormValidation::RULE_VALID_EMAIL,
- $oFormValidation::rule(
- $oFormValidation::RULE_IS_UNIQUE, $oUserEmailModel->getTableName(), 'email'
- ),
- ] : [],
- 'username' => in_array(Config::get('APP_NATIVE_LOGIN_USING'), [
- 'USERNAME',
- 'BOTH',
- ]) ? [
- $oFormValidation::RULE_REQUIRED,
- $oFormValidation::rule(
- $oFormValidation::RULE_IS_UNIQUE, $oUserModel->getTableName(), 'username'
- ),
- ] : [],
+ (new Identity())
+ ->addRules([
+ 'first_name' => [FormValidation::RULE_REQUIRED],
+ 'last_name' => [FormValidation::RULE_REQUIRED],
+ 'password' => [FormValidation::RULE_REQUIRED],
])
- ->setMessages([
- $oFormValidation::RULE_IS_UNIQUE => implode('', [
- Config::get('APP_NATIVE_LOGIN_USING') === 'EMAIL'
- ? lang('auth_register_email_is_unique', siteUrl('auth/password/forgotten'))
- : null,
- Config::get('APP_NATIVE_LOGIN_USING') === 'USERNAME'
- ? lang('auth_register_username_is_unique', siteUrl('auth/password/forgotten'))
- : null,
- Config::get('APP_NATIVE_LOGIN_USING') === 'BOTH'
- ? lang('auth_register_identity_is_unique', siteUrl('auth/password/forgotten'))
- : null,
- ]),
- ])
- ->run();
+ ->run($oInput->post());
if (appSetting('user_registration_captcha_enabled', 'auth')) {
if (!$oCaptchaService->verify()) {
diff --git a/src/Admin/Controller/Accounts.php b/src/Admin/Controller/Accounts.php
index ad38f570..175b485b 100755
--- a/src/Admin/Controller/Accounts.php
+++ b/src/Admin/Controller/Accounts.php
@@ -22,6 +22,7 @@
use Nails\Auth\Model\User;
use Nails\Auth\Model\User\Group;
use Nails\Auth\Model\User\Password;
+use Nails\Auth\Validator\User\Identity;
use Nails\Common\Exception\FactoryException;
use Nails\Common\Exception\ModelException;
use Nails\Common\Exception\NailsException;
@@ -363,38 +364,18 @@ public function create(): void
if ($oInput->post()) {
- /** @var FormValidation $oFormValidation */
- $oFormValidation = Factory::service('FormValidation');
-
- $aRules = [
- 'group_id' => [FormValidation::RULE_REQUIRED, FormValidation::RULE_IS_NATURAL_NO_ZERO],
- 'first_name' => [FormValidation::RULE_REQUIRED, FormValidation::rule(FormValidation::RULE_MAX_LENGTH, 150)],
- 'last_name' => [FormValidation::RULE_REQUIRED, FormValidation::rule(FormValidation::RULE_MAX_LENGTH, 150)],
- 'email' => [
- FormValidation::RULE_REQUIRED,
- FormValidation::RULE_VALID_EMAIL,
- FormValidation::rule(FormValidation::RULE_IS_UNIQUE, Config::get('NAILS_DB_PREFIX') . 'user_email', 'email'),
- FormValidation::rule(FormValidation::RULE_MAX_LENGTH, 255),
- ],
- ];
-
- if (in_array(Config::get('APP_NATIVE_LOGIN_USING'), ['BOTH', 'USERNAME'])) {
- $aRules['username'] = [
- FormValidation::RULE_REQUIRED,
- FormValidation::rule(FormValidation::RULE_MAX_LENGTH, 150),
- FormValidation::RULE_ALPHA_DASH_PERIOD,
- FormValidation::rule(FormValidation::RULE_IS_UNIQUE, Config::get('NAILS_DB_PREFIX') . 'user', 'username'),
- ];
- }
-
try {
- $oFormValidation
- ->buildValidator($aRules, [
+ (new Identity())
+ ->addRules([
+ 'group_id' => [FormValidation::RULE_REQUIRED, FormValidation::RULE_IS_NATURAL_NO_ZERO],
+ 'first_name' => [FormValidation::RULE_REQUIRED, FormValidation::rule(FormValidation::RULE_MAX_LENGTH, 150)],
+ 'last_name' => [FormValidation::RULE_REQUIRED, FormValidation::rule(FormValidation::RULE_MAX_LENGTH, 150)],
+ ])
+ ->setMessages([
FormValidation::RULE_IS_NATURAL_NO_ZERO => lang('fv_required'),
- FormValidation::RULE_IS_UNIQUE => lang('fv_email_already_registered'),
])
- ->run();
+ ->run($oInput->post());
// Success
diff --git a/src/Validator/User/Identifier.php b/src/Validator/User/Identifier.php
new file mode 100644
index 00000000..d765b6cd
--- /dev/null
+++ b/src/Validator/User/Identifier.php
@@ -0,0 +1,34 @@
+ Config::get('APP_NATIVE_LOGIN_USING') === 'EMAIL'
+ ? [FormValidation::RULE_REQUIRED, FormValidation::RULE_VALID_EMAIL]
+ : [FormValidation::RULE_REQUIRED],
+ ];
+ }
+}
diff --git a/src/Validator/User/Identity.php b/src/Validator/User/Identity.php
new file mode 100644
index 00000000..f9d47f62
--- /dev/null
+++ b/src/Validator/User/Identity.php
@@ -0,0 +1,129 @@
+addRules(['first_name' => [FormValidation::RULE_REQUIRED]])
+ * ->run($oInput->post());
+ *
+ * Pass a user ID to exempt that user's own email/username from the uniqueness check
+ * when validating an edit. Override a field with `[]` to leave it unvalidated.
+ *
+ * @package Nails
+ * @subpackage module-auth
+ * @category Validator
+ * @author Nails Dev Team
+ * @link
+ */
+
+namespace Nails\Auth\Validator\User;
+
+use Nails\Auth\Constants;
+use Nails\Auth\Model\User;
+use Nails\Common\Exception\FactoryException;
+use Nails\Common\Factory\Service\FormValidation\Validator;
+use Nails\Common\Service\FormValidation;
+use Nails\Config;
+use Nails\Factory;
+
+class Identity extends Validator
+{
+ /**
+ * @param int|null $iIgnoreUserId A user whose own email/username should not count as taken
+ */
+ public function __construct(private readonly ?int $iIgnoreUserId = null)
+ {
+ parent::__construct();
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Whether the app identifies users by email
+ */
+ public static function usesEmail(): bool
+ {
+ return in_array(Config::get('APP_NATIVE_LOGIN_USING'), ['EMAIL', 'BOTH'], true);
+ }
+
+ /**
+ * Whether the app identifies users by username
+ */
+ public static function usesUsername(): bool
+ {
+ return in_array(Config::get('APP_NATIVE_LOGIN_USING'), ['USERNAME', 'BOTH'], true);
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @throws FactoryException
+ */
+ protected function rules(): array
+ {
+ $aRules = [];
+
+ if (static::usesEmail()) {
+ /** @var User\Email $oUserEmailModel */
+ $oUserEmailModel = Factory::model('UserEmail', Constants::MODULE_SLUG);
+
+ $aRules['email'] = [
+ 'trim',
+ FormValidation::RULE_REQUIRED,
+ FormValidation::RULE_VALID_EMAIL,
+ FormValidation::rule(FormValidation::RULE_MAX_LENGTH, 255),
+ $this->uniqueRule($oUserEmailModel->getTableName(), 'email', 'user_id'),
+ ];
+ }
+
+ if (static::usesUsername()) {
+ /** @var User $oUserModel */
+ $oUserModel = Factory::model('User', Constants::MODULE_SLUG);
+
+ $aRules['username'] = [
+ 'trim',
+ FormValidation::RULE_REQUIRED,
+ FormValidation::rule(FormValidation::RULE_MAX_LENGTH, 150),
+ FormValidation::RULE_ALPHA_DASH_PERIOD,
+ $this->uniqueRule($oUserModel->getTableName(), 'username', 'id'),
+ ];
+ }
+
+ return $aRules;
+ }
+
+ // --------------------------------------------------------------------------
+
+ protected function messages(): array
+ {
+ $sKey = match (Config::get('APP_NATIVE_LOGIN_USING')) {
+ 'EMAIL' => 'auth_register_email_is_unique',
+ 'USERNAME' => 'auth_register_username_is_unique',
+ default => 'auth_register_identity_is_unique',
+ };
+
+ return [
+ FormValidation::RULE_IS_UNIQUE => lang($sKey, siteUrl('auth/password/forgotten')),
+ ];
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Compiles an `is_unique` rule, exempting the ignored user if one was given
+ *
+ * @param string $sTable The table to check
+ * @param string $sColumn The column holding the value
+ * @param string $sUserIdColumn The column holding the user ID in that table
+ */
+ protected function uniqueRule(string $sTable, string $sColumn, string $sUserIdColumn): string
+ {
+ return $this->iIgnoreUserId
+ ? FormValidation::rule(FormValidation::RULE_IS_UNIQUE, $sTable, $sColumn, $this->iIgnoreUserId, $sUserIdColumn)
+ : FormValidation::rule(FormValidation::RULE_IS_UNIQUE, $sTable, $sColumn);
+ }
+}
diff --git a/tests/Validator/User/IdentifierTest.php b/tests/Validator/User/IdentifierTest.php
new file mode 100644
index 00000000..a484c913
--- /dev/null
+++ b/tests/Validator/User/IdentifierTest.php
@@ -0,0 +1,37 @@
+run($aData);
+ return [];
+ } catch (ValidationException $e) {
+ return $e->getData();
+ }
+ }
+
+ public function test_the_identifier_must_be_an_email_when_logging_in_by_email(): void
+ {
+ Config::set('APP_NATIVE_LOGIN_USING', 'EMAIL');
+
+ self::assertSame(['identifier' => 'This must be a valid email.'], $this->errorsFor(['identifier' => 'ada']));
+ self::assertSame([], $this->errorsFor(['identifier' => 'ada@example.com']));
+ }
+
+ public function test_any_value_will_do_otherwise(): void
+ {
+ Config::set('APP_NATIVE_LOGIN_USING', 'BOTH');
+
+ self::assertSame(['identifier' => 'This field is required.'], $this->errorsFor(['identifier' => '']));
+ self::assertSame([], $this->errorsFor(['identifier' => 'ada']));
+ }
+}
diff --git a/tests/Validator/User/IdentityTest.php b/tests/Validator/User/IdentityTest.php
new file mode 100644
index 00000000..a505b6de
--- /dev/null
+++ b/tests/Validator/User/IdentityTest.php
@@ -0,0 +1,123 @@
+ taken values)
+ */
+ private function validator(array $aTaken = [], ?int $iIgnoreUserId = null): Identity
+ {
+ return (new Identity($iIgnoreUserId))
+ ->stubRule(FormValidation::RULE_IS_UNIQUE, function ($mValue, Context $oContext) use ($aTaken) {
+ [, $sColumn] = $oContext->getParams();
+ return !in_array($mValue, $aTaken[$sColumn] ?? [], true);
+ });
+ }
+
+ private function errorsFor(Identity $oValidator, array $aData): array
+ {
+ try {
+ $oValidator->run($aData);
+ return [];
+ } catch (ValidationException $e) {
+ return $e->getData();
+ }
+ }
+
+ // --------------------------------------------------------------------------
+
+ public function test_both_fields_are_required_when_logging_in_with_both(): void
+ {
+ self::assertSame(
+ ['email', 'username'],
+ array_keys($this->errorsFor($this->validator(), []))
+ );
+ }
+
+ public function test_a_clean_identity_passes_and_is_trimmed(): void
+ {
+ $oValidator = $this->validator();
+ $oValidator->run(['email' => ' ada@example.com ', 'username' => ' ada.lovelace ']);
+
+ self::assertSame(
+ ['email' => 'ada@example.com', 'username' => 'ada.lovelace'],
+ $oValidator->getValidatedData()
+ );
+ }
+
+ public function test_a_taken_identity_reports_the_already_registered_message(): void
+ {
+ $aErrors = $this->errorsFor(
+ $this->validator(['email' => ['ada@example.com']]),
+ ['email' => 'ada@example.com', 'username' => 'ada']
+ );
+
+ self::assertSame(['email'], array_keys($aErrors));
+ self::assertStringContainsString('already registered', $aErrors['email']);
+ }
+
+ public function test_the_ignored_user_is_passed_to_the_uniqueness_rule(): void
+ {
+ $aSeen = [];
+ $oValidator = (new Identity(42))
+ ->stubRule(FormValidation::RULE_IS_UNIQUE, function ($mValue, Context $oContext) use (&$aSeen) {
+ $aSeen[$oContext->getField()] = $oContext->getParams();
+ return true;
+ });
+
+ $oValidator->run(['email' => 'ada@example.com', 'username' => 'ada']);
+
+ self::assertSame(['42', 'user_id'], array_slice($aSeen['email'], 2));
+ self::assertSame(['42', 'id'], array_slice($aSeen['username'], 2));
+ }
+
+ public function test_malformed_values_fail_before_uniqueness_is_checked(): void
+ {
+ $aErrors = $this->errorsFor(
+ $this->validator(),
+ ['email' => 'not-an-email', 'username' => 'has spaces!']
+ );
+
+ self::assertSame('This must be a valid email.', $aErrors['email']);
+ self::assertStringContainsString('alpha-numeric', $aErrors['username']);
+ }
+
+ public function test_only_the_configured_identity_is_validated(): void
+ {
+ Config::set('APP_NATIVE_LOGIN_USING', 'EMAIL');
+
+ self::assertSame(['email'], array_keys($this->validator()->getRules()));
+ self::assertSame([], $this->errorsFor($this->validator(), ['email' => 'ada@example.com']));
+ }
+
+ public function test_a_field_can_be_switched_off_by_overriding_it_with_no_rules(): void
+ {
+ $oValidator = $this->validator()->setRules(['username' => []]);
+
+ self::assertSame([], $this->errorsFor($oValidator, ['email' => 'ada@example.com']));
+ }
+
+ public function test_extra_rules_merge_with_the_identity_rules(): void
+ {
+ $oValidator = $this->validator()->addRules(['first_name' => [FormValidation::RULE_REQUIRED]]);
+
+ self::assertSame(
+ ['email', 'username', 'first_name'],
+ array_keys($this->errorsFor($oValidator, []))
+ );
+ }
+}
From 28c5b58cb0ab52255d6b09ac52e6d0a8dcf158c3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pablo=20de=20la=20Pen=CC=83a?=
Date: Fri, 4 Sep 2026 09:45:51 +0100
Subject: [PATCH 5/7] refactor: Read the target user via `Context` in the merge
validator
The merge rule compared against `$oInput->post('user_id')`; it now reads the field from the data being validated via `Context::getValue()`.
Co-Authored-By: Claude Fable 5.1
(cherry picked from commit 4b314132bd256cfb75bc314736f2875203cd7f73)
---
src/Admin/Controller/Merge.php | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/src/Admin/Controller/Merge.php b/src/Admin/Controller/Merge.php
index dbe07950..f29228b6 100644
--- a/src/Admin/Controller/Merge.php
+++ b/src/Admin/Controller/Merge.php
@@ -22,6 +22,7 @@
use Nails\Common\Exception\ValidationException;
use Nails\Common\Service\FormValidation;
use Nails\Common\Service\Input;
+use Nails\Common\Validation\Context;
use Nails\Factory;
use stdClass;
@@ -88,12 +89,12 @@ public function index(): void
],
'merge_ids' => [
$oFormValidation::RULE_REQUIRED,
- function ($mInput) use ($oInput) {
+ function ($mInput, Context $oContext) {
$aMergeIds = explode(',', $mInput);
if (in_array(activeUser('id'), $aMergeIds)) {
throw new ValidationException('You cannot list yourself as a user to merge.');
- } elseif (in_array($oInput->post('user_id'), $aMergeIds)) {
+ } elseif (in_array($oContext->getValue('user_id'), $aMergeIds)) {
throw new ValidationException('You cannot merge the target user into itself.');
}
},
From 9ad8cc902ea662f12f23ca15793804c28bc7ca57 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pablo=20de=20la=20Pen=CC=83a?=
+ User import is unavailable until the template is corrected.
+
+
+
=form_open_multipart()?>
@@ -29,6 +45,8 @@
Please note: The CSV you supply should be in the correct format, as per the template
which you can download above. Remember to include the header rows describing each column.
+ Every row is validated when you upload; if any of them cannot be imported the file is rejected
+ and nothing is created, so you can correct it and try again.
diff --git a/admin/views/Import/preview.php b/admin/views/Import/preview.php
index 9e17a188..0a15304a 100644
--- a/admin/views/Import/preview.php
+++ b/admin/views/Import/preview.php
@@ -1,65 +1,336 @@
$aRegistered
*/
+$bIsDraft = $oImport->status === Status::DRAFT;
+$iColumns = count($aKeys) + 1;
+$iRowCount = (int) ($oImport->row_count ?? 0);
+
+/**
+ * Rows whose email or username already belongs to an account. The CSV is not
+ * wrong - these rows are simply redundant - so rather than rejecting the file
+ * the admin is offered the chance to import the rest without them.
+ */
+$aRegistered = $aRegistered ?? [];
+$iRegistered = count($aRegistered);
+$iImportable = max(0, $iRowCount - $iRegistered);
+
+/**
+ * The confirmation can be exact rather than hedging with "up to": both routes
+ * to it - ticking the skip checkbox below, or Continue on the warning an
+ * unticked Import opens - agree to skip exactly these rows.
+ */
+$sConfirmBody = sprintf(
+ '%s %s will be created in the background%s. This cannot be undone.',
+ number_format($iImportable),
+ $iImportable === 1 ? 'user account' : 'user accounts',
+ $iRegistered
+ ? sprintf(
+ ', and %s %s will be skipped',
+ number_format($iRegistered),
+ $iRegistered === 1 ? 'row' : 'rows'
+ )
+ : ''
+);
+
+/**
+ * The warning an unticked Import opens, reusing the alert's own phrasing so the
+ * modal does not introduce a second account of the same problem. Continue is an
+ * implied tick, so the copy names both halves of what it agrees to.
+ */
+$sWarnTitle = sprintf(
+ '%s %s already registered',
+ number_format($iRegistered),
+ $iRegistered === 1 ? 'row is' : 'rows are'
+);
+
+$sWarnBody = sprintf(
+ 'An account already exists for %s of the rows in this CSV, so %s cannot be imported. '
+ . 'Continue to import the remaining %s and skip %s, or cancel and correct your CSV.',
+ number_format($iRegistered),
+ $iRegistered === 1 ? 'it' : 'they',
+ number_format($iImportable),
+ $iRegistered === 1 ? 'it' : 'them'
+);
+
+/**
+ * Delete is a plain button which UserImport turns into a
+ * DELETE /api/auth/import/{id}; see \Nails\Auth\Api\Controller\Import. Unlike
+ * the approve form it does not degrade - with the bundle broken it does nothing
+ * - which is the same bargain the preview table below already makes.
+ *
+ * `type="button"` is load bearing: on a draft this sits inside the approve
+ * form, and the default `submit` would start the import.
+ *
+ * The status and the created count are handed over so that the confirmation
+ * copy can be composed in one place, in JS, rather than written out again here.
+ */
+/**
+ * Details is a plain button which UserImport turns into a modal; see
+ * assets/js/components/UserImport.js. Offered only when there is something to
+ * show - a stored reason, or rows which failed or warned - so a clean import
+ * does not get a button which opens an empty box.
+ */
+$bHasDetails = $oImport->status->isTerminal()
+ && ($oImport->error || $oImport->error_count || $oImport->warning_count);
+
+$sDetailsButton = $bHasDetails
+ ? sprintf(
+ '',
+ $oImport->id
+ )
+ : null;
+
+$sDeleteButton = $oImport->status->isDeletable()
+ ? sprintf(
+ '',
+ implode(' ', [
+ 'data-import-id="' . $oImport->id . '"',
+ 'data-delete-url="' . siteUrl('api/auth/import/' . $oImport->id) . '"',
+ 'data-redirect="' . siteUrl($sListUrl) . '"',
+ 'data-status="' . $oImport->status->value . '"',
+ // Warned rows have accounts too; see Enum\User\Import\ItemStatus
+ 'data-created-count="' . ($oImport->success_count + $oImport->warning_count) . '"',
+ ])
+ )
+ : null;
+
+/**
+ * A draft can be imported or deleted; anything else which is deletable gets
+ * Delete alone, with nothing to save. A job a runner holds gets no controls at
+ * all, so the bar is absent entirely for those two statuses.
+ */
+if ($bIsDraft) {
+ $aControls = [
+ 'save' => ['text' => 'Import'],
+ 'html' => ['right' => $sDeleteButton],
+ ];
+} elseif ($sDeleteButton || $sDetailsButton) {
+ $aControls = [
+ 'save' => ['enabled' => false],
+ 'html' => ['right' => trim($sDetailsButton . ' ' . $sDeleteButton)],
+ ];
+} else {
+ $aControls = null;
+}
+
+/**
+ * Mirrors how module-admin normalises a column label into a cell class; see
+ * module-admin/admin/views/DefaultController/index.php
+ */
+$fFieldClass = function (string $sLabel): string {
+ $sLabel = strtolower($sLabel);
+ $sLabel = preg_replace('/[^a-z0-9 \-_]/', '', $sLabel);
+ return 'field field--' . str_replace([' ', '_'], '-', $sLabel);
+};
+
?>
-
- Please review the following data
- Your CSV has been processed, and the following values have been ascertained. Please verify them, and when
- happy to continue, click "Import" below.
-
- =form_open()?>
-
-
-
-
-
-
-
=$sKey?>
+
+
+ Please review the following data
+ Your CSV has been uploaded and every row has been validated. Please verify the values below, and
+ when happy to continue, click "Import" below. The import runs in the background; you can leave this page
+ once it has started.
+
+
+
+
+ =number_format($iRegistered)?>
+ =$iRegistered === 1 ? 'row is' : 'rows are'?> already registered
+
+ An account already exists for the following, so
+ =$iRegistered === 1 ? 'it' : 'they'?> cannot be imported. Tick the box below to import the
+ remaining =number_format($iImportable)?>, or correct your CSV and upload it again.
+
+
+
[
- 'text' => 'Import',
- 'name' => 'action',
- 'value' => 'import',
- ],
- ]);
+ if ($aControls) {
+ echo Helper::floatingControls($aControls);
+ }
+
+ if ($bIsDraft) {
+ echo form_close();
+ }
?>
- =form_close()?>
diff --git a/assets/css/admin.min.css b/assets/css/admin.min.css
index 961d2d50..d87c9558 100644
--- a/assets/css/admin.min.css
+++ b/assets/css/admin.min.css
@@ -1 +1 @@
-.group-accounts.all table{margin-bottom:1em}.group-accounts.all table th.id,.group-accounts.all table th.group,.group-accounts.all table td.id,.group-accounts.all table td.group{text-align:center}.group-accounts.all table th.id,.group-accounts.all table td.id{width:50px;min-width:50px}.group-accounts.all table th.group,.group-accounts.all table td.group{width:150px;min-width:150px}.group-accounts.all table th.actions,.group-accounts.all table td.actions{width:225px;min-width:225px;text-align:center}.group-accounts.all table td.details img.profile-img{float:left;margin-right:10px;width:65px;height:65px;background:#ccc;border:1px solid #ccc;border-radius:2px}.group-accounts.all table td.details img.verified{margin-left:5px;width:10px;height:10px;position:relative;top:2px}.group-accounts.all table td.id,.group-accounts.all table td.group{vertical-align:middle;color:#666}.group-accounts.all table td.profile.zero{color:#ccc}.group-accounts.all table td.actions .not-editable{color:#aaa}.group-accounts.all table td .awesome{margin-right:5px;margin-bottom:5px;padding:8px 5px 6px 5px}.group-accounts.all table td div{float:left}.group-accounts.all table td div small{display:block}.group-accounts.create #user-group-descriptions li,.group-accounts.create #user-group-pwrules li{margin:0}.group-accounts.edit .social .icon{float:left;border:1px solid #999;border-radius:3px;background:#f3f3f3;padding:5px;margin-right:15px;padding-left:42px;line-height:16px;background-position:5px center;background-repeat:no-repeat;word-wrap:break-word}.group-accounts.edit .social p{margin-top:5px}.group-accounts.edit .uploads ul{max-height:500px;overflow:auto}.group-accounts.edit .uploads ul li{vertical-align:middle;display:block;margin-bottom:0}.group-accounts.edit .uploads ul li a{padding:1em;display:block;position:relative;min-height:51px;background:#efefef;background-clip:padding-box;text-decoration:none}.group-accounts.edit .uploads ul li a small{display:block}.group-accounts.edit .uploads ul li a.image{padding-left:55px}.group-accounts.edit .uploads ul li a.image img{position:absolute;left:5px;top:5px;width:35px;height:35px;border:1px solid #ccc;margin:0;margin-right:10px;vertical-align:middle;padding:2px;background:#fff;border-radius:2px}.group-accounts.edit .uploads ul li.no-data{color:#777}.group-accounts.edit table.emails th.email input,.group-accounts.edit table.emails td.email input{width:100%;box-sizing:border-box}.group-accounts.edit table.emails th.actions,.group-accounts.edit table.emails td.actions{width:220px}.group-accounts.groups table th,.group-accounts.groups table td{vertical-align:middle}.group-accounts.groups table th.actions,.group-accounts.groups table th.actions,.group-accounts.groups table td.actions,.group-accounts.groups table td.actions{min-width:220px;text-align:center}.group-accounts.groups table th.default,.group-accounts.groups table th.default,.group-accounts.groups table td.default,.group-accounts.groups table td.default{width:50px;text-align:center}.group-accounts.groups table th.default span,.group-accounts.groups table th.default span,.group-accounts.groups table td.default span,.group-accounts.groups table td.default span{font-size:1.5em}.group-accounts.groups.edit .permission-groups .permission-group th.enabled{width:50px}.group-accounts.groups.edit .permission-groups .permission-group td{cursor:pointer}.group-accounts.groups.edit .permission-groups .permission-group td.enabled{padding:0}.group-accounts.groups.edit .permission-groups .permission-group td.enabled label{width:100%;height:100%;cursor:pointer;line-height:40px}.group-accounts.groups.edit .search{border:none;border-bottom:1px solid #efefef}.group-accounts.groups.edit .search .search-text{margin:0}.group-accounts.merge.merge-preview table{margin-bottom:1.5em}.group-accounts.merge.merge-preview table th.userId,.group-accounts.merge.merge-preview table td.userId{text-align:center;width:50px}.group-accounts.merge.merge-preview table th.user-cell,.group-accounts.merge.merge-preview table td.user-cell{width:auto}.group-accounts.merge.merge-preview table th.tableRows,.group-accounts.merge.merge-preview table td.tableRows{text-align:center;width:100px}.group-accounts.change-group table th.userId,.group-accounts.change-group table td.userId{text-align:center;width:50px}
+.group-accounts.all table{margin-bottom:1em}.group-accounts.all table th.id,.group-accounts.all table th.group,.group-accounts.all table td.id,.group-accounts.all table td.group{text-align:center}.group-accounts.all table th.id,.group-accounts.all table td.id{width:50px;min-width:50px}.group-accounts.all table th.group,.group-accounts.all table td.group{width:150px;min-width:150px}.group-accounts.all table th.actions,.group-accounts.all table td.actions{width:225px;min-width:225px;text-align:center}.group-accounts.all table td.details img.profile-img{float:left;margin-right:10px;width:65px;height:65px;background:#ccc;border:1px solid #ccc;border-radius:2px}.group-accounts.all table td.details img.verified{margin-left:5px;width:10px;height:10px;position:relative;top:2px}.group-accounts.all table td.id,.group-accounts.all table td.group{vertical-align:middle;color:#666}.group-accounts.all table td.profile.zero{color:#ccc}.group-accounts.all table td.actions .not-editable{color:#aaa}.group-accounts.all table td .awesome{margin-right:5px;margin-bottom:5px;padding:8px 5px 6px 5px}.group-accounts.all table td div{float:left}.group-accounts.all table td div small{display:block}.group-accounts.create #user-group-descriptions li,.group-accounts.create #user-group-pwrules li{margin:0}.group-accounts.edit .social .icon{float:left;border:1px solid #999;border-radius:3px;background:#f3f3f3;padding:5px;margin-right:15px;padding-left:42px;line-height:16px;background-position:5px center;background-repeat:no-repeat;word-wrap:break-word}.group-accounts.edit .social p{margin-top:5px}.group-accounts.edit .uploads ul{max-height:500px;overflow:auto}.group-accounts.edit .uploads ul li{vertical-align:middle;display:block;margin-bottom:0}.group-accounts.edit .uploads ul li a{padding:1em;display:block;position:relative;min-height:51px;background:#efefef;background-clip:padding-box;text-decoration:none}.group-accounts.edit .uploads ul li a small{display:block}.group-accounts.edit .uploads ul li a.image{padding-left:55px}.group-accounts.edit .uploads ul li a.image img{position:absolute;left:5px;top:5px;width:35px;height:35px;border:1px solid #ccc;margin:0;margin-right:10px;vertical-align:middle;padding:2px;background:#fff;border-radius:2px}.group-accounts.edit .uploads ul li.no-data{color:#777}.group-accounts.edit table.emails th.email input,.group-accounts.edit table.emails td.email input{width:100%;box-sizing:border-box}.group-accounts.edit table.emails th.actions,.group-accounts.edit table.emails td.actions{width:220px}.group-accounts.groups table th,.group-accounts.groups table td{vertical-align:middle}.group-accounts.groups table th.actions,.group-accounts.groups table th.actions,.group-accounts.groups table td.actions,.group-accounts.groups table td.actions{min-width:220px;text-align:center}.group-accounts.groups table th.default,.group-accounts.groups table th.default,.group-accounts.groups table td.default,.group-accounts.groups table td.default{width:50px;text-align:center}.group-accounts.groups table th.default span,.group-accounts.groups table th.default span,.group-accounts.groups table td.default span,.group-accounts.groups table td.default span{font-size:1.5em}.group-accounts.groups.edit .permission-groups .permission-group th.enabled{width:50px}.group-accounts.groups.edit .permission-groups .permission-group td{cursor:pointer}.group-accounts.groups.edit .permission-groups .permission-group td.enabled{padding:0}.group-accounts.groups.edit .permission-groups .permission-group td.enabled label{width:100%;height:100%;cursor:pointer;line-height:40px}.group-accounts.groups.edit .search{border:none;border-bottom:1px solid #efefef}.group-accounts.groups.edit .search .search-text{margin:0}.group-accounts.merge.merge-preview table{margin-bottom:1.5em}.group-accounts.merge.merge-preview table th.userId,.group-accounts.merge.merge-preview table td.userId{text-align:center;width:50px}.group-accounts.merge.merge-preview table th.user-cell,.group-accounts.merge.merge-preview table td.user-cell{width:auto}.group-accounts.merge.merge-preview table th.tableRows,.group-accounts.merge.merge-preview table td.tableRows{text-align:center;width:100px}.group-accounts.change-group table th.userId,.group-accounts.change-group table td.userId{text-align:center;width:50px}.badge.badge--default{background-color:#555}.badge.badge--info{background-color:#31708f}.badge.badge--success{background-color:#3c763d}.badge.badge--warning{background-color:#8a6d3b}.badge.badge--danger{background-color:#a94442}@-webkit-keyframes user-import-spin{to{transform:rotate(360deg)}}@keyframes user-import-spin{to{transform:rotate(360deg)}}.user-import-spinner{display:inline-block;width:.85em;height:.85em;vertical-align:-0.1em;border:2px solid currentColor;border-right-color:rgba(0,0,0,0);border-radius:50%;-webkit-animation:user-import-spin .75s linear infinite;animation:user-import-spin .75s linear infinite}.user-import-details p{margin:0 0 .5rem}.user-import-details__log,.user-import-details__rows{max-height:40vh;overflow:auto;margin:0 0 .75rem;padding:.75rem;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-word;background-color:#f5f5f5;border:1px solid #d9d9d9;border-radius:4px}.module-auth.import th.actions,.module-auth.import td.actions{width:275px;min-width:275px}.module-auth.import--preview #user-import-preview tbody{transition:opacity 150ms ease-in-out}.module-auth.import--preview #user-import-preview[aria-busy=true] tbody{opacity:.4}.module-auth.import--preview #user-import-preview[aria-busy=true] .pagination a{pointer-events:none}.module-auth.import--preview th.field--line,.module-auth.import--preview td.field--line{width:60px;text-align:center}
diff --git a/assets/js/admin.js b/assets/js/admin.js
index ac26b93c..a49f283d 100644
--- a/assets/js/admin.js
+++ b/assets/js/admin.js
@@ -6,6 +6,7 @@ import AccountEdit from './components/AccountEdit.js';
import AccountMerge from './components/AccountMerge.js';
import Groups from './components/Groups.js';
import SearchUser from './components/SearchUser.js';
+import UserImport from './components/UserImport.js';
(function() {
window.NAILS.ADMIN.registerPlugin(
@@ -33,5 +34,10 @@ import SearchUser from './components/SearchUser.js';
'SearchUser',
new SearchUser(window.NAILS.ADMIN)
);
+ window.NAILS.ADMIN.registerPlugin(
+ 'nails/module-auth',
+ 'UserImport',
+ new UserImport(window.NAILS.ADMIN)
+ );
})();
diff --git a/assets/js/components/UserImport.js b/assets/js/components/UserImport.js
new file mode 100644
index 00000000..4afbe942
--- /dev/null
+++ b/assets/js/components/UserImport.js
@@ -0,0 +1,1617 @@
+/**
+ * Drives the two pieces of the user import UI which need live data: the list of
+ * recent imports on the index, and the paged preview of an uploaded CSV.
+ *
+ * The admin bundle is loaded on every admin page, so each half no-ops unless its
+ * container is in the DOM.
+ */
+class UserImport {
+
+ /**
+ * Construct UserImport
+ * @param {Object} adminController The admin controller
+ */
+ constructor(adminController) {
+
+ this.adminController = adminController;
+
+ /**
+ * How often, in ms, to poll while something might change
+ * @type {Number}
+ */
+ this.pollActive = 2500;
+
+ /**
+ * How often, in ms, to poll when everything has settled
+ * @type {Number}
+ */
+ this.pollIdle = 30000;
+
+ /**
+ * How many page links either side of the current page the paginator
+ * shows; matches the `num_links` the admin pagination partial configures
+ * @type {Number}
+ */
+ this.numLinks = 5;
+
+ this.endpoint = window.SITE_URL + 'api/auth/import';
+
+ this.dom = {
+ list: document.getElementById('user-import-list'),
+ listBody: document.getElementById('user-import-list-body'),
+ preview: document.getElementById('user-import-preview'),
+ previewBody: document.getElementById('user-import-preview-body')
+ };
+
+ this.imports = [];
+ this.listTimeout = null;
+
+ /**
+ * The shared modal, created on first use. Null means "not yet asked for",
+ * false means "asked for and unavailable".
+ * @type {Object|Boolean|null}
+ */
+ this.modal = null;
+
+ /**
+ * Null until the first page of the preview has loaded; this is what
+ * distinguishes the first load from a page change
+ * @type {Number|null}
+ */
+ this.previewPage = null;
+
+ /**
+ * The IDs of imports whose delete request is in flight, consulted when
+ * the actions cell renders so that the button's busy state survives a
+ * poll landing mid-request
+ * @type {Set}
+ */
+ this.deleting = new Set();
+
+ /**
+ * Whether a confirmation is on screen. The modal is shared, so a second
+ * confirm() would reassign its onHide and leave the first promise
+ * unsettled.
+ * @type {Boolean}
+ */
+ this.confirming = false;
+
+ this.bindConfirmForms();
+ this.bindDelete();
+ this.bindDetails();
+
+ if (this.dom.list && this.dom.listBody) {
+ this.adminController.log('Constructing user import list');
+ this.loadList();
+ }
+
+ if (this.dom.preview && this.dom.previewBody) {
+ this.adminController.log('Constructing user import preview');
+ this.bindPreview();
+
+ /**
+ * When the CSV has gone the server has already said so in the table,
+ * and the API could only 404; asking would just stack a modal on top
+ * of the flash error which is already on the page.
+ */
+ if (!this.dom.preview.dataset.csvMissing) {
+ this.loadPreview(1);
+ }
+ }
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Fetches a URL and returns the decoded API envelope
+ *
+ * On failure the rejection carries the response's status, and the API's own
+ * message where it got far enough to render one: the poll only needs to
+ * know that something went wrong, but a delete the user asked for has to be
+ * able to say what.
+ *
+ * @param {String} url The URL to fetch
+ * @param {Object} options Any fetch options to apply, e.g. {method: 'DELETE'}
+ * @return {Promise} Resolves with the parsed response body
+ */
+ request(url, options) {
+
+ let settings = Object.assign({method: 'GET', credentials: 'same-origin'}, options || {});
+
+ // Merged over the caller's, so a caller cannot drop what the API needs
+ settings.headers = Object.assign({}, settings.headers, {
+ 'Accept': 'application/json',
+ 'X-Requested-With': 'XMLHttpRequest'
+ });
+
+ return fetch(url, settings)
+ .then((response) => response.text().then((body) => {
+
+ let payload = null;
+
+ /**
+ * In production an unhandled exception is re-thrown to the
+ * global error handler, so a 500 arrives as an HTML page rather
+ * than an envelope; the status is then all we have to go on.
+ */
+ try {
+ payload = body ? JSON.parse(body) : null;
+ } catch (e) {
+ payload = null;
+ }
+
+ if (!response.ok) {
+ let error = new Error(
+ (payload && payload.error) || `Request failed with status ${response.status}`
+ );
+ error.status = response.status;
+ throw error;
+ }
+
+ return payload || {};
+ }));
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Loads, and schedules the next load of, the list of recent imports
+ * @return {void}
+ */
+ loadList() {
+
+ /**
+ * The preview page runs the same class but has no list, and the delete
+ * handlers resync unconditionally; without this a failed delete there
+ * would reach renderList() and fall over on a null container.
+ */
+ if (!this.dom.list || !this.dom.listBody) {
+ return;
+ }
+
+ clearTimeout(this.listTimeout);
+
+ this.request(this.endpoint)
+ .then((response) => {
+
+ let imports = response.data || [];
+
+ this.renderList(imports);
+
+ // Poll quickly while something is in flight, slowly otherwise
+ let isBusy = imports.some((item) => {
+ return ['PENDING', 'VALIDATING', 'RUNNING'].indexOf(item.status) !== -1;
+ });
+
+ this.listTimeout = setTimeout(() => {
+ this.loadList();
+ }, isBusy ? this.pollActive : this.pollIdle);
+ })
+ .catch((error) => {
+
+ /**
+ * This path polls every few seconds, so it must never raise a
+ * modal; one flaky response would otherwise become an unclosable
+ * stream of dialogs. The next poll will recover on its own.
+ */
+ this.adminController.warn('Failed to load user imports', error);
+
+ this.listTimeout = setTimeout(() => {
+ this.loadList();
+ }, this.pollIdle);
+ });
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Reconciles the rendered rows against the response
+ * @param {Array} imports The imports as returned by the API
+ * @return {void}
+ */
+ renderList(imports) {
+
+ let responseIds = imports.map((item) => item.id);
+
+ for (let i = 0; i < imports.length; i++) {
+ let existing = this.imports.find((x) => x.id === imports[i].id);
+ if (existing) {
+ /**
+ * The stored item is what the delete confirmation reads its
+ * status and counts from, so it has to keep up; `dom` survives
+ * because the response never carries one.
+ */
+ Object.assign(existing, imports[i]);
+ this.updateRow(existing.dom, imports[i]);
+ } else {
+ this.addRow(imports[i]);
+ }
+ }
+
+ // Reverse loop so items can be spliced out as we go
+ for (let i = this.imports.length - 1; i >= 0; i--) {
+ if (responseIds.indexOf(this.imports[i].id) === -1) {
+ if (this.imports[i].dom && this.imports[i].dom.parentNode) {
+ this.imports[i].dom.parentNode.removeChild(this.imports[i].dom);
+ }
+ this.imports.splice(i, 1);
+ }
+ }
+
+ /**
+ * Revealed on the first successful response and never hidden again, so an
+ * admin with no imports sees the empty state rather than nothing at all.
+ */
+ this.dom.list.classList.remove('hidden');
+ this.toggleListEmptyRow();
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Adds or removes the list's empty state row
+ * @return {void}
+ */
+ toggleListEmptyRow() {
+
+ let row = this.dom.listBody.querySelector('tr.user-import-list-empty');
+
+ if (this.imports.length) {
+ if (row) {
+ row.parentNode.removeChild(row);
+ }
+ return;
+ }
+
+ if (!row) {
+ row = document.createElement('tr');
+ row.classList.add('user-import-list-empty');
+ row.appendChild(this.createCell(
+ 'no-data',
+ 'No imports found',
+ this.countColumns(this.dom.list)
+ ));
+ this.dom.listBody.appendChild(row);
+ }
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Drops an import from the list, and from the reconciliation set
+ *
+ * Splicing `imports` is the point: leave the entry behind and the next
+ * poll's find() matches it, updateRow() writes into a detached node, the
+ * row never comes back, and the empty state never appears either.
+ *
+ * @param {Number} id The import to remove
+ * @return {void}
+ */
+ removeImport(id) {
+
+ let index = this.imports.findIndex((x) => x.id === id);
+ if (index === -1) {
+ return;
+ }
+
+ let row = this.imports[index].dom;
+ if (row && row.parentNode) {
+ row.parentNode.removeChild(row);
+ }
+
+ this.imports.splice(index, 1);
+ this.toggleListEmptyRow();
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Adds a row for an import which was not previously rendered
+ * @param {Object} item The import
+ * @return {void}
+ */
+ addRow(item) {
+
+ let tr = document.createElement('tr');
+
+ tr.appendChild(this.createCell(['field', 'field--id'], this.getIdCellHtml(item)));
+ tr.appendChild(this.createCell(['field', 'field--file'], this.getFileCellHtml(item)));
+ tr.appendChild(this.createCell(['field', 'field--status'], this.getStatusCellHtml(item)));
+ tr.appendChild(this.createCell(['field', 'field--progress'], this.getProgressCellHtml(item)));
+ tr.appendChild(this.createCell(['field', 'field--requested', 'datetime'], this.getRequestedCellHtml(item)));
+ tr.appendChild(this.createCell(['field', 'field--finished', 'datetime'], this.getFinishedCellHtml(item)));
+
+ /**
+ * Left genuinely empty when there is nothing to do, so that admin's
+ * `td.actions:empty:before` supplies the "No Actions" label
+ */
+ tr.appendChild(this.createCell('actions', this.getActionsCellHtml(item)));
+
+ item.dom = tr;
+ this.imports.push(item);
+ this.dom.listBody.appendChild(tr);
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Updates the cells of an already rendered import
+ * @param {Object} dom The row element
+ * @param {Object} item The import
+ * @return {void}
+ */
+ updateRow(dom, item) {
+
+ this.setCellHtml(dom, 'td.field--status', this.getStatusCellHtml(item));
+ this.setCellHtml(dom, 'td.field--progress', this.getProgressCellHtml(item));
+ this.setCellHtml(dom, 'td.field--finished', this.getFinishedCellHtml(item));
+ this.setCellHtml(dom, 'td.actions', this.getActionsCellHtml(item));
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Sets a cell's contents, if the cell exists
+ *
+ * Guarded because this runs inside the poll loop; a renamed class should cost
+ * one stale cell, not a stream of errors every few seconds.
+ *
+ * @param {HTMLElement} row The row
+ * @param {String} selector The cell's selector
+ * @param {String} html The cell's contents
+ * @return {void}
+ */
+ setCellHtml(row, selector, html) {
+ let cell = row.querySelector(selector);
+ if (cell) {
+ cell.innerHTML = html;
+ }
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * The ID cell's contents
+ * @param {Object} item The import
+ * @return {String} The cell's HTML
+ */
+ getIdCellHtml(item) {
+ return this.escape(item.id);
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * The file cell's contents
+ * @param {Object} item The import
+ * @return {String} The cell's HTML
+ */
+ getFileCellHtml(item) {
+ let name = item.source && item.source.filename ? item.source.filename : 'CSV';
+ return `${this.escape(name)}`;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * The status cell's contents
+ *
+ * The badge carries the colour; the cell itself is left untinted, so a row
+ * no longer changes colour wholesale. admin's `td small { display: block }`
+ * puts the error underneath without any help from us.
+ *
+ * @param {Object} item The import
+ * @return {String} The cell's HTML
+ */
+ getStatusCellHtml(item) {
+
+ let html = `` +
+ this.escape(item.status) +
+ '';
+
+ /**
+ * The summary line only - the stored error is deliberately multi-line
+ * (see Processor::composeError()) and the whole thing here would turn
+ * every failed row into a wall of text. The Details button has the rest.
+ */
+ if (item.error) {
+ html += `${this.escape(item.error_summary || item.error)}`;
+ }
+
+ return html;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * The badge modifier for an import's status
+ *
+ * Mapped rather than derived: the return goes straight into a `class`
+ * attribute, and a lookup is what guarantees it can only ever be one of the
+ * five modifiers the stylesheet declares - including for a status case
+ * shipped after this bundle was last built.
+ *
+ * @param {Object} item The import
+ * @return {String} The modifier class
+ */
+ getStatusVariant(item) {
+ return {
+ DRAFT: 'badge--default',
+ PENDING: 'badge--info',
+ VALIDATING: 'badge--info',
+ RUNNING: 'badge--info',
+ COMPLETE: 'badge--success',
+ PARTIAL: 'badge--warning',
+ FAILED: 'badge--danger'
+ }[item.status] || 'badge--default';
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * The progress cell's contents
+ * @param {Object} item The import
+ * @return {String} The cell's HTML
+ */
+ getProgressCellHtml(item) {
+
+ let progress = item.progress || {};
+ let total = progress.row_count || 0;
+
+ if (!total) {
+ return '—';
+ }
+
+ let cursor = item.status === 'VALIDATING'
+ ? progress.validated_count
+ : progress.processed_count;
+
+ let html = `${this.numberFormat(cursor)} of ${this.numberFormat(total)} (${progress.percent}%)`;
+
+ /**
+ * Warnings get their own line rather than being folded into the errors:
+ * an errored row has no account and a warned one does, so they are not
+ * the same number to anybody reading this.
+ */
+ if (progress.error_count) {
+ html += `${this.numberFormat(progress.error_count)} errored`;
+ }
+
+ if (progress.warning_count) {
+ html += `${this.numberFormat(progress.warning_count)} warned`;
+ }
+
+ return html;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * The requested cell's contents
+ * @param {Object} item The import
+ * @return {String} The cell's HTML
+ */
+ getRequestedCellHtml(item) {
+ let when = item.created ? item.created.formatted : '';
+ let who = item.user ? item.user.name : null;
+ return who ? `${when}${this.escape(who)}` : when;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * The finished cell's contents
+ * @param {Object} item The import
+ * @return {String} The cell's HTML
+ */
+ getFinishedCellHtml(item) {
+ return item.finished
+ ? item.finished.formatted
+ : '—';
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * The actions cell's contents
+ * @param {Object} item The import
+ * @return {String} The cell's HTML
+ */
+ getActionsCellHtml(item) {
+
+ let buttons = [];
+
+ if (item.status === 'DRAFT') {
+ buttons.push(`Review`);
+ }
+
+ if (this.hasDetails(item)) {
+ buttons.push('`);
+ }
+
+ if (item.log && item.log.url) {
+ buttons.push(`Log`);
+ }
+
+ if (this.isDeletable(item)) {
+ /**
+ * Rendered busy while the request is in flight, because the poll
+ * re-renders this cell out from under the button - every 2.5s while
+ * something is active, and every 30s when nothing is, which is the
+ * case that actually bites.
+ */
+ buttons.push(this.deleting.has(item.id)
+ ? ''
+ : '`
+ );
+ }
+
+ return buttons.join(' ');
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Whether an import can be deleted
+ *
+ * Mirrors Status::isDeletable(). The server checks again and answers 409 if
+ * this is out of date, which it will be for up to one poll.
+ *
+ * @param {Object} item The import
+ * @return {Boolean} Whether it can be deleted
+ */
+ isDeletable(item) {
+ return ['VALIDATING', 'RUNNING'].indexOf(item.status) === -1;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Whether an import has anything worth opening a modal for
+ *
+ * Either a stored reason, or per-row failures or warnings to list. A clean
+ * import has none of them and gets no button, which is what keeps the list
+ * uncluttered.
+ *
+ * @param {Object} item The import
+ * @return {Boolean} Whether there is detail to show
+ */
+ hasDetails(item) {
+ let progress = item.progress || {};
+ return Boolean(item.error) ||
+ Boolean(progress.error_count) ||
+ Boolean(progress.warning_count);
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Wires up the preview's paging
+ * @return {void}
+ */
+ bindPreview() {
+
+ this.dom.previewPagingTop = this.dom.preview.querySelector('[data-paging="top"]');
+ this.dom.previewPagingBottom = this.dom.preview.querySelector('[data-paging="bottom"]');
+
+ /**
+ * Delegated, because both paginators are re-rendered on every response;
+ * listeners bound to the buttons themselves would go stale immediately.
+ */
+ this.dom.preview
+ .addEventListener('click', (event) => {
+
+ let link = event.target.closest('a[data-page]');
+ if (!link) {
+ return;
+ }
+
+ event.preventDefault();
+
+ // Anchors cannot be disabled, so the busy state is the guard
+ if (this.dom.preview.getAttribute('aria-busy') === 'true') {
+ return;
+ }
+
+ this.loadPreview(parseInt(link.dataset.page, 10), link);
+ });
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Loads a page of the CSV preview
+ * @param {Number} page The page to load
+ * @param {HTMLElement} trigger The button which asked for it, if any
+ * @return {void}
+ */
+ loadPreview(page, trigger) {
+
+ let id = this.dom.preview.dataset.importId;
+ let isFirstLoad = this.previewPage === null;
+
+ /**
+ * On the first load the server has already rendered a spinner row, so
+ * there is nothing to dim and no button to put a spinner on.
+ */
+ if (!isFirstLoad) {
+ this.setPreviewBusy(true, trigger);
+ }
+
+ this.request(`${this.endpoint}/${id}/rows?page=${page}`)
+ .then((response) => {
+
+ this.previewPage = page;
+ this.renderPreview(response.data || [], response.meta || {});
+
+ // The first load is already at the top of the page; only a page change moves
+ if (!isFirstLoad) {
+ this.scrollToPreview();
+ }
+ })
+ .catch((error) => {
+
+ this.adminController.warn('Failed to load user import preview', error);
+
+ /**
+ * On a page change the rows on screen are still valid, so they
+ * stay; losing what you were reading because page seven timed out
+ * is worse than the error itself.
+ */
+ if (isFirstLoad) {
+ this.renderPreviewMessage('The preview could not be loaded');
+ }
+
+ this.alert(
+ 'The preview could not be loaded',
+ 'Something went wrong fetching this page of the CSV. Please reload the page to try again.'
+ );
+ })
+ .finally(() => {
+ this.setPreviewBusy(false);
+ });
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Toggles the preview's busy state
+ *
+ * `aria-busy` doubles as the styling hook, so no class has to be invented; it
+ * is also what stops the paging links being followed, since an anchor has no
+ * disabled state.
+ *
+ * @param {Boolean} isBusy Whether a request is in flight
+ * @param {HTMLElement} trigger The link which asked for it, if any
+ * @return {void}
+ */
+ setPreviewBusy(isBusy, trigger) {
+
+ this.dom.preview.setAttribute('aria-busy', isBusy ? 'true' : 'false');
+
+ if (isBusy) {
+
+ if (trigger) {
+ this.previewTrigger = {el: trigger, label: trigger.innerHTML};
+ trigger.innerHTML = '';
+ }
+
+ } else if (this.previewTrigger) {
+
+ /**
+ * A successful load re-renders the paginator, taking the link with
+ * it; the link is only still around when the request failed, and then
+ * it must not be left spinning.
+ */
+ if (this.previewTrigger.el.isConnected) {
+ this.previewTrigger.el.innerHTML = this.previewTrigger.label;
+ }
+
+ this.previewTrigger = null;
+ }
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Renders a page of the CSV preview
+ * @param {Array} rows The rows to render
+ * @param {Object} meta The response's meta data
+ * @return {void}
+ */
+ renderPreview(rows, meta) {
+
+ let header = meta.header || [];
+ let pagination = meta.pagination || {};
+
+ if (!rows.length) {
+ this.renderPreviewMessage('There is nothing to preview');
+
+ } else {
+
+ this.dom.previewBody.innerHTML = '';
+
+ for (let i = 0; i < rows.length; i++) {
+
+ let tr = document.createElement('tr');
+ tr.appendChild(this.createCell(['field', 'field--line'], String(rows[i].line)));
+
+ for (let j = 0; j < header.length; j++) {
+ let value = rows[i].data[header[j]];
+ tr.appendChild(this.createCell(
+ this.fieldClass(header[j]).split(' '),
+ value === null || value === '' || typeof value === 'undefined'
+ ? '—'
+ : this.escape(String(value))
+ ));
+ }
+
+ this.dom.previewBody.appendChild(tr);
+ }
+ }
+
+ this.renderPaginator(this.dom.previewPagingTop, pagination);
+ this.renderPaginator(this.dom.previewPagingBottom, pagination);
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Replaces the preview's rows with a single full width message
+ * @param {String} message The message to show
+ * @return {void}
+ */
+ renderPreviewMessage(message) {
+
+ let tr = document.createElement('tr');
+ tr.appendChild(this.createCell(
+ 'no-data',
+ this.escape(message),
+ this.countColumns(this.dom.preview)
+ ));
+
+ this.dom.previewBody.innerHTML = '';
+ this.dom.previewBody.appendChild(tr);
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Renders a paginator into a container
+ *
+ * This mirrors the markup produced by module-admin's
+ * `admin/views/_components/pagination.php`, which is the source of truth, with
+ * the same `num_links: 5` / `use_page_numbers: true` configuration. The
+ * partial cannot be used directly because paging happens over the API with no
+ * page load, so the whole paginator is redrawn from each response instead.
+ *
+ * The links must be anchors: admin styles them as
+ * `.pagination ul li.page a`, so a button would come out unstyled.
+ *
+ * @param {HTMLElement} container The element to render into
+ * @param {Object} pagination The `meta.pagination` object from the API
+ * @return {void}
+ */
+ renderPaginator(container, pagination) {
+
+ if (!container) {
+ return;
+ }
+
+ let page = pagination.page || 1;
+ let perPage = pagination.per_page || 0;
+ let total = pagination.total || 0;
+ let pages = perPage ? Math.ceil(total / perPage) : 0;
+
+ // "Records X to Y of Z", arithmetic lifted from the partial
+ let start = (page * perPage) - perPage;
+ let end = start + perPage;
+ if (total > 0) {
+ start++;
+ }
+ if (end > total) {
+ end = total;
+ }
+
+ let items = [];
+
+ // Like CodeIgniter, render no links at all for an empty or single page
+ if (pages > 1) {
+
+ if (page > this.numLinks + 1) {
+ items.push(this.paginatorItem('First', 1, 'first'));
+ }
+
+ if (page !== 1) {
+ items.push(this.paginatorItem('‹', page - 1, 'previous'));
+ }
+
+ let from = Math.max(1, page - this.numLinks);
+ let to = Math.min(pages, page + this.numLinks);
+
+ for (let i = from; i <= to; i++) {
+ items.push(i === page
+ ? `
',
+ `Records ${start} to ${end} of ${this.numberFormat(total)}`,
+ items.length ? `
${items.join('')}
` : '',
+ '',
+ '
'
+ ].join('');
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * A single clickable paginator item
+ * @param {String} label The item's label; may contain entities
+ * @param {Number} page The page the item navigates to
+ * @param {String} modifier An extra class for the item, if any
+ * @return {String} The item's HTML
+ */
+ paginatorItem(label, page, modifier) {
+ return `
';
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Brings the top of the preview back into view
+ *
+ * Paging from the bottom paginator replaces the rows above the viewport, so
+ * without this the page you just asked for is off-screen and has to be
+ * scrolled to by hand.
+ *
+ * @return {void}
+ */
+ scrollToPreview() {
+
+ // Measured rather than hard coded; admin's header is fixed and overlaps
+ let header = document.querySelector('body > .header');
+ let offset = (header ? header.offsetHeight : 0) + 8;
+
+ let top = this.dom.preview.getBoundingClientRect().top + window.scrollY - offset;
+
+ let reduceMotion = window.matchMedia
+ && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+
+ window.scrollTo({
+ top: Math.max(0, top),
+ behavior: reduceMotion ? 'auto' : 'smooth'
+ });
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Creates a table cell
+ * @param {String|Array} classes The classes to apply
+ * @param {String} html The cell's contents
+ * @param {Number} colspan The number of columns to span, if any
+ * @return {Object} The cell element
+ */
+ createCell(classes, html, colspan) {
+
+ let cell = document.createElement('td');
+
+ if (Array.isArray(classes)) {
+ cell.classList.add(...classes);
+ } else {
+ cell.classList.add(classes);
+ }
+
+ if (colspan) {
+ cell.setAttribute('colspan', colspan);
+ }
+
+ if (html) {
+ cell.innerHTML = html;
+ }
+
+ return cell;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * The number of columns in a container's table
+ * @param {HTMLElement} container The element containing the table
+ * @return {Number} The column count
+ */
+ countColumns(container) {
+ return container.querySelectorAll('thead th').length;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Normalises a column label into a cell class, as module-admin does
+ * @param {String} label The column's label
+ * @return {String} The cell's classes
+ */
+ fieldClass(label) {
+ return 'field field--' + String(label)
+ .toLowerCase()
+ .replace(/[^a-z0-9 \-_]/g, '')
+ .replace(/[ _]/g, '-');
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Formats a number as PHP's number_format() does
+ *
+ * Deliberately not toLocaleString(); number_format() is not locale aware, so
+ * this is what actually matches the markup we are mirroring.
+ *
+ * @param {Number} value The value to format
+ * @return {String} The formatted value
+ */
+ numberFormat(value) {
+ return String(value || 0).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Returns the shared modal, creating it on first use
+ * @return {Object|null} The modal instance, or null if admin's Modal is unavailable
+ */
+ getModal() {
+
+ if (this.modal === null) {
+ try {
+ this.modal = this.adminController.getInstance('Modal', 'nails/module-admin').create();
+ } catch (e) {
+ this.adminController.warn('Admin\'s Modal component is unavailable', e);
+ this.modal = false;
+ }
+ }
+
+ return this.modal || null;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Shows a message in a modal
+ * @param {String} title The modal's title
+ * @param {String} message The message to show
+ * @return {void}
+ */
+ alert(title, message) {
+
+ let modal = this.getModal();
+
+ if (!modal) {
+ window.alert(`${title}\n\n${message}`);
+ return;
+ }
+
+ let p = document.createElement('p');
+ p.innerText = message;
+
+ // Clear anything a previous confirm() left on the shared instance
+ modal.onHide(() => {
+ });
+
+ modal
+ .setTitle(this.escape(title))
+ .setBody(p)
+ .clearActions()
+ .addAction('Close', ['btn-primary'], (event, instance) => instance.hide())
+ .show();
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Asks the user to confirm an action
+ * @param {String} title The modal's title
+ * @param {String} message The question to ask
+ * @param {String} action The confirming action's label
+ * @param {Boolean} warn Whether to present the question as a warning
+ * @return {Promise} Resolves with whether the action was confirmed
+ */
+ confirm(title, message, action, warn) {
+
+ return new Promise((resolve) => {
+
+ let modal = this.getModal();
+
+ if (!modal) {
+ // Better an ugly confirmation than a button which silently does nothing
+ resolve(window.confirm(message));
+ return;
+ }
+
+ let settled = false;
+ let settle = (confirmed) => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ resolve(confirmed);
+ };
+
+ let p = document.createElement('p');
+ p.innerText = message;
+
+ let body = p;
+
+ if (warn) {
+
+ /**
+ * Wrapped in an alert so the question reads as a warning at a
+ * glance, rather than as the same paragraph every other
+ * confirmation shows. Following Notes::showError() in
+ * module-admin, which hands setBody() an `.alert` of its own -
+ * the message still goes in through innerText, the alert is the
+ * only thing being added.
+ */
+ body = document.createElement('div');
+ body.classList.add('alert', 'alert-warning');
+
+ /**
+ * Inline rather than a rule in this module's stylesheet: the
+ * admin bundle is global, so `.modal__body > .alert` would
+ * reach every other admin modal too - module-admin's own alerts
+ * included - to spare this one the 20px it does not need.
+ */
+ body.style.marginBottom = '0';
+
+ body.appendChild(p);
+ }
+
+ // Escape to close is built into the modal, and counts as a cancellation
+ modal.onHide(() => settle(false));
+
+ modal
+ .setTitle(this.escape(title))
+ .setBody(body)
+ .clearActions()
+ .addAction(this.escape(action), ['btn-primary'], (event, instance) => {
+ settle(true);
+ instance.hide();
+ })
+ .addAction('Cancel', ['btn-danger'], (event, instance) => {
+ settle(false);
+ instance.hide();
+ })
+ .show();
+ });
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Routes the submission of flagged forms through a modal confirmation
+ *
+ * The listener is on the form rather than the button so that the Delete
+ * button, which is adopted into the floating controls by the `form`
+ * attribute, keeps working.
+ *
+ * @return {void}
+ */
+ bindConfirmForms() {
+
+ document
+ .querySelectorAll('form.js-user-import-confirm')
+ .forEach((form) => {
+ form.addEventListener('submit', (event) => {
+
+ event.preventDefault();
+
+ /**
+ * Held for the whole chain, not just one modal: the shared
+ * instance has a single onHide slot, so a Delete
+ * confirmation opening over this one would leave this
+ * promise unsettled.
+ */
+ this.confirming = true;
+
+ this
+ .confirmForm(form)
+ .then((confirmed) => {
+ if (confirmed) {
+ /**
+ * Called off the prototype in case a control is
+ * ever named "submit", which would shadow it.
+ * Note this does not fire the submit event, so it
+ * cannot loop back into this handler.
+ */
+ HTMLFormElement.prototype.submit.call(form);
+ }
+ })
+ .finally(() => {
+ this.confirming = false;
+ });
+ });
+ });
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Confirms a form's submission, warning about it first if it needs it
+ *
+ * A form which names a `data-warn-field` checkbox, and has it unticked,
+ * gets a warning before the confirmation; Continue is an implied tick, and
+ * chains straight on into that confirmation. Anything else - no such field,
+ * or a box already ticked - goes straight there, exactly as every other
+ * flagged form does.
+ *
+ * The two run in sequence over the one shared modal, which keeps them clear
+ * of module-admin's stacking, and there is nothing to see in between: the
+ * hide and the re-show both land in the same task.
+ *
+ * @param {HTMLFormElement} form The form being submitted
+ * @return {Promise} Resolves with whether the submission was confirmed
+ */
+ confirmForm(form) {
+
+ let checkbox = this.getWarnCheckbox(form);
+
+ // A local, so the regular confirmation is written once for both routes
+ let confirmSubmit = () => this.confirm(
+ form.dataset.confirmTitle || 'Are you sure?',
+ form.dataset.confirmBody || 'Please confirm you\'d like to continue with this action.',
+ form.dataset.confirmAction || 'OK'
+ );
+
+ if (!checkbox || checkbox.checked) {
+ return confirmSubmit();
+ }
+
+ return this
+ .confirm(
+ form.dataset.warnTitle || 'Are you sure?',
+ form.dataset.warnBody || 'Please confirm you\'d like to continue with this action.',
+ form.dataset.warnAction || 'Continue',
+ true
+ )
+ .then((acknowledged) => {
+
+ if (!acknowledged) {
+ return false;
+ }
+
+ /**
+ * The real checkbox, rather than a hidden input, so the value
+ * reaches the server exactly as a tick would and the page shows
+ * what was agreed to. It stays ticked if the confirmation is
+ * then cancelled - the decision was made, and a second Import
+ * click should not have to make it again.
+ */
+ checkbox.checked = true;
+
+ return confirmSubmit();
+ });
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Resolves the checkbox a form wants warning about, if it names one
+ *
+ * Looked up through `form.elements` rather than interpolated into a
+ * selector, so a server-authored name never becomes part of one.
+ *
+ * @param {HTMLFormElement} form The form being submitted
+ * @return {HTMLElement|null} The checkbox, or null if the form has no such field
+ */
+ getWarnCheckbox(form) {
+
+ let name = form.dataset.warnField;
+
+ if (!name) {
+ return null;
+ }
+
+ let field = form.elements[name];
+
+ // A repeated name comes back as a RadioNodeList, which has no `type`
+ return field && field.type === 'checkbox' ? field : null;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Wires up the Details buttons on both the list and the preview
+ *
+ * Delegated from the document for the same two reasons bindDelete() is; see
+ * the note there.
+ *
+ * @return {void}
+ */
+ bindDetails() {
+
+ document.addEventListener('click', (event) => {
+
+ let button = event.target.closest
+ ? event.target.closest('button.js-user-import-details')
+ : null;
+
+ if (button) {
+ event.preventDefault();
+ this.showDetails(parseInt(button.dataset.importId, 10));
+ }
+ });
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Shows what went wrong with an import
+ *
+ * The modal opens straight away with a spinner rather than after the
+ * requests land, so a click always does something visible; the body is
+ * replaced in place once the detail arrives.
+ *
+ * @param {Number} id The import's ID
+ * @return {void}
+ */
+ showDetails(id) {
+
+ let modal = this.getModal();
+ let cached = this.imports.find((item) => item.id === id);
+
+ if (!modal) {
+ window.alert(cached && cached.error
+ ? cached.error
+ : 'The details for this import are unavailable.');
+ return;
+ }
+
+ let loading = document.createElement('p');
+ let spinner = document.createElement('span');
+ spinner.className = 'user-import-spinner';
+ spinner.setAttribute('aria-hidden', 'true');
+ loading.appendChild(spinner);
+ loading.appendChild(document.createTextNode(' Loading the details\u2026'));
+
+ // Clear anything a previous confirm() left on the shared instance
+ modal.onHide(() => {
+ });
+
+ modal
+ .setTitle(`Import #${id}`)
+ .setBody(loading)
+ .clearActions()
+ .addAction('Close', ['btn-primary'], (event, instance) => instance.hide())
+ .show();
+
+ Promise
+ .all([
+ this.request(`${this.endpoint}/${id}`),
+ this.request(`${this.endpoint}/${id}/items?status=ERROR,WARNING`)
+ ])
+ .then(([job, items]) => {
+
+ let item = job.data || {};
+
+ modal
+ .setBody(this.buildDetailsBody(item, items.data || [], items.meta || {}))
+ .clearActions();
+
+ if (item.log && item.log.url) {
+ modal.addAction('Download the full log', ['btn-default'], () => {
+ window.location = item.log.url;
+ });
+ }
+
+ modal.addAction('Close', ['btn-primary'], (event, instance) => instance.hide());
+ })
+ .catch((error) => {
+
+ this.adminController.warn('Failed to load user import details', error);
+
+ let p = document.createElement('p');
+ p.innerText = error.message || 'The details for this import could not be loaded.';
+
+ modal.setBody(p);
+ });
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Builds the details modal's body
+ *
+ * Everything goes in via textContent rather than innerHTML. Both blocks
+ * carry values lifted straight out of the uploaded CSV, so there is no
+ * escaping decision to get wrong here.
+ *
+ * @param {Object} item The import, as returned by the API
+ * @param {Array} rows The import's failing and warned rows
+ * @param {Object} meta The rows response's meta
+ * @return {DocumentFragment} The body
+ */
+ buildDetailsBody(item, rows, meta) {
+
+ let fragment = document.createDocumentFragment();
+
+ if (item.error) {
+ let log = document.createElement('pre');
+ log.className = 'user-import-details__log';
+ log.textContent = item.error;
+ fragment.appendChild(log);
+ }
+
+ if (rows.length) {
+
+ let total = (meta.pagination || {}).total || rows.length;
+
+ if (total > rows.length) {
+ let note = document.createElement('p');
+ note.className = 'text-muted';
+ note.textContent = 'Showing the first ' + this.numberFormat(rows.length) +
+ ' of ' + this.numberFormat(total) + ' affected rows; the log has every one.';
+ fragment.appendChild(note);
+ }
+
+ /**
+ * Each line carries its status, because the two the request asks for
+ * do not mean the same thing: an ERROR row has no account, a WARNING
+ * row has one which needs finishing by hand.
+ */
+ let list = document.createElement('pre');
+ list.className = 'user-import-details__rows';
+ list.textContent = rows
+ .map((row) => `Line ${row.line} [${row.status}]: ` +
+ `${row.message || '(no reason was recorded)'}`)
+ .join('\n');
+ fragment.appendChild(list);
+ }
+
+ /**
+ * A bad header or a missing CSV fails before a single row is recorded,
+ * so an empty modal is a real outcome and needs saying out loud.
+ */
+ if (!fragment.childNodes.length) {
+ let p = document.createElement('p');
+ p.textContent = 'No further detail was recorded for this import.';
+ fragment.appendChild(p);
+ }
+
+ return fragment;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Wires up the Delete buttons on both the list and the preview
+ *
+ * Delegated from the document rather than from a container, for two
+ * reasons: the list's buttons are re-rendered on every poll, and the
+ * preview's button is rendered by admin's floating controls, which sit
+ * outside `#user-import-preview` - so a listener scoped the way
+ * bindPreview() scopes its own would never see it. The class is specific
+ * enough that this costs nothing on the admin pages which have neither.
+ *
+ * @return {void}
+ */
+ bindDelete() {
+
+ document.addEventListener('click', (event) => {
+
+ let button = event.target.closest
+ ? event.target.closest('button.js-user-import-delete')
+ : null;
+
+ if (button) {
+ event.preventDefault();
+ this.deleteImport(button);
+ }
+ });
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Deletes an import, once the user has confirmed it
+ *
+ * @param {HTMLElement} button The Delete button which was clicked
+ * @return {void}
+ */
+ deleteImport(button) {
+
+ let id = parseInt(button.dataset.importId, 10);
+
+ // A confirmation is already up, or this one is already on its way out
+ if (this.confirming || this.deleting.has(id)) {
+ return;
+ }
+
+ /**
+ * The list knows the item; the preview page does not, and hands the two
+ * things the copy turns on over as attributes instead.
+ */
+ let item = this.imports.find((x) => x.id === id);
+ let status = item ? item.status : button.dataset.status;
+
+ // A warned row has an account too, so it is one of the accounts which stays
+ let created = item
+ ? (((item.progress || {}).success_count || 0) + ((item.progress || {}).warning_count || 0))
+ : parseInt(button.dataset.createdCount || '0', 10);
+
+ let label = button.innerHTML;
+
+ this.confirming = true;
+
+ this
+ .confirm(
+ 'Delete this import?',
+ this.getDeleteConfirmBody(status, created),
+ 'Delete'
+ )
+ .then((confirmed) => {
+
+ this.confirming = false;
+
+ if (!confirmed) {
+ return;
+ }
+
+ /**
+ * The clicked button is styled here for immediate feedback, and
+ * the id is recorded so that getActionsCellHtml() reproduces
+ * that state if the poll re-renders the cell mid-request.
+ */
+ this.deleting.add(id);
+ button.disabled = true;
+ button.innerHTML = '';
+
+ return this
+ .request(button.dataset.deleteUrl, {method: 'DELETE'})
+ .then(() => this.onDeleted(id, button))
+ .catch((error) => this.onDeleteFailed(id, error))
+ .finally(() => {
+
+ /**
+ * In `finally`, never in `then`: an id left in the set
+ * by a failed request would disable that row's button
+ * on every subsequent poll, with no way back but a
+ * reload.
+ */
+ this.deleting.delete(id);
+
+ // Only still here if the delete failed; success took the row, or the page
+ if (button.isConnected) {
+ button.disabled = false;
+ button.innerHTML = label;
+ }
+ });
+ });
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Drops a deleted import, and resyncs
+ *
+ * The row goes now rather than on the next poll; at pollIdle that would be
+ * half a minute of a row which is already gone. loadList() then reconciles
+ * against the server, and clears the pending timer as it goes.
+ *
+ * @param {Number} id The import which was deleted
+ * @param {HTMLElement} button The button which asked
+ * @return {void}
+ */
+ onDeleted(id, button) {
+
+ this.removeImport(id);
+
+ // The preview is a page about something which no longer exists
+ if (button.dataset.redirect) {
+ window.location = button.dataset.redirect;
+ return;
+ }
+
+ this.loadList();
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Reports a delete which did not happen
+ *
+ * loadList()'s refusal to raise a modal does not apply here: that is about
+ * not stacking dialogs every few seconds, and this is a one-off the user
+ * asked for.
+ *
+ * @param {Number} id The import which was not deleted
+ * @param {Error} error The rejection, carrying .status where we got that far
+ * @return {void}
+ */
+ onDeleteFailed(id, error) {
+
+ this.adminController.warn('Failed to delete user import', error);
+
+ /**
+ * Already gone - most likely deleted in another tab. There is nothing
+ * to apologise for; the row disappearing is the whole message.
+ */
+ if (error.status === 404) {
+ this.removeImport(id);
+ this.loadList();
+ return;
+ }
+
+ this.alert(
+ 'The import could not be deleted',
+ error.status === 409
+ ? 'This import has started running since the page was loaded, so it can no longer be deleted.'
+ : error.message
+ );
+
+ // Whatever the server thinks, the list should agree with it
+ this.loadList();
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * The body of the delete confirmation
+ *
+ * What matters is not the status but whether accounts exist: deleting the
+ * job never deletes the users it created, and a FAILED job may well have
+ * created some before it stopped - `user_import_item.user_id` is
+ * ON DELETE SET NULL precisely so that it cannot reach them. Saying
+ * otherwise invites an admin to read "Delete" as "undo".
+ *
+ * @param {String} status The import's status
+ * @param {Number} created How many accounts it created
+ * @return {String} The message
+ */
+ getDeleteConfirmBody(status, created) {
+
+ if (created) {
+ return 'The uploaded CSV and its log will be deleted. The ' +
+ `${this.numberFormat(created)} user ` +
+ `${created === 1 ? 'account' : 'accounts'} this import created ` +
+ 'will NOT be deleted. This cannot be undone.';
+ }
+
+ if (status === 'DRAFT' || status === 'PENDING') {
+ return 'The uploaded CSV will be deleted and no users will be created. This cannot be undone.';
+ }
+
+ return 'The uploaded CSV and its log will be deleted. ' +
+ 'This import created no user accounts. This cannot be undone.';
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Escapes a value for insertion into the DOM
+ * @param {String} value The value to escape
+ * @return {String} The escaped value
+ */
+ escape(value) {
+ let div = document.createElement('div');
+ div.appendChild(document.createTextNode(value));
+ return div.innerHTML;
+ }
+}
+
+export default UserImport;
diff --git a/assets/sass/admin.scss b/assets/sass/admin.scss
index 621a5bc3..1e2b4d63 100644
--- a/assets/sass/admin.scss
+++ b/assets/sass/admin.scss
@@ -278,3 +278,147 @@
}
}
}
+
+/**
+ * Contextual badges. module-admin ships a bare `.badge` and deliberately no
+ * variants - it does not import Bootstrap's `_labels.scss`, and `.label` there
+ * is a form-label selector (`float: left; width: 130px`), so it must not be
+ * pressed into service as a pill.
+ *
+ * Even the neutral case needs an explicit colour: admin's own badge background
+ * is `rgba(0, 0, 0, 0.25)`, which over a white cell resolves to #bfbfbf - 1.8:1
+ * against the white text Bootstrap gives it, a flat contrast failure.
+ *
+ * The hexes are admin's `$color-*-text` palette written out longhand, because
+ * this stylesheet is standalone and cannot reach its variables. All five clear
+ * 4.5:1 against white at the 12px bold Bootstrap's base rule sets, which also
+ * supplies the colour, weight and radius. Doubled up on `.badge` so the rules
+ * do not depend on which stylesheet loads last.
+ */
+.badge {
+ &.badge--default {
+ background-color: #555555; // 7.5:1
+ }
+
+ &.badge--info {
+ background-color: #31708f; // 5.5:1
+ }
+
+ &.badge--success {
+ background-color: #3c763d; // 5.5:1
+ }
+
+ &.badge--warning {
+ background-color: #8a6d3b; // 4.9:1
+ }
+
+ &.badge--danger {
+ background-color: #a94442; // 5.9:1
+ }
+}
+
+/**
+ * A spinner of our own. The admin theme has no reusable one - its only spinner
+ * is scoped to `.search .mask` - and Bootstrap's `.spinner-border` is not in the
+ * installed build, so this is self contained.
+ */
+@keyframes user-import-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.user-import-spinner {
+ display: inline-block;
+ width: 0.85em;
+ height: 0.85em;
+ vertical-align: -0.1em;
+ border: 2px solid currentColor;
+ border-right-color: transparent;
+ border-radius: 50%;
+ animation: user-import-spin 0.75s linear infinite;
+}
+
+/**
+ * The details modal's body. Top level, alongside the spinner, because admin's
+ * Modal appends its container to `document.body` - outside `.module-auth` - so
+ * a rule nested in there would never match.
+ */
+.user-import-details {
+ p {
+ margin: 0 0 0.5rem;
+ }
+
+ /**
+ * The modal body already scrolls (`max-height: 80vh` in module-admin's
+ * modal.scss), so each block is capped and owns its own scroll; a scroller
+ * nested inside a scroller makes both awkward to use.
+ *
+ * `pre-wrap` is what keeps a long "already registered" message wrapping
+ * instead of forcing the whole modal sideways.
+ */
+ &__log,
+ &__rows {
+ max-height: 40vh;
+ overflow: auto;
+ margin: 0 0 0.75rem;
+ padding: 0.75rem;
+ font-size: 12px;
+ line-height: 1.5;
+ white-space: pre-wrap;
+ word-break: break-word;
+ background-color: #f5f5f5;
+ border: 1px solid #d9d9d9;
+ border-radius: 4px;
+ }
+}
+
+.module-auth {
+ &.import {
+ /**
+ * Four btn-xs now that Details is there alongside Review, Log and
+ * Delete; the 225px this used to carry wrapped them.
+ */
+ th.actions,
+ td.actions {
+ width: 275px;
+ min-width: 275px;
+ }
+ }
+
+ &.import--preview {
+ #user-import-preview {
+ /**
+ * Fade the outgoing rows while the next page is in flight. module-admin
+ * has no busy convention to borrow - the `.search > .mask` spinner's
+ * `search-spin` keyframes are never declared, so it does not animate -
+ * so this is the one rule we own. `aria-busy` doubles as the hook, so
+ * no class has to be invented.
+ */
+ tbody {
+ transition: opacity 150ms ease-in-out;
+ }
+
+ &[aria-busy='true'] {
+ tbody {
+ opacity: 0.4;
+ }
+
+ // Anchors have no disabled state, so stop the clicks here too
+ .pagination a {
+ pointer-events: none;
+ }
+ }
+ }
+
+ /**
+ * The line number is a gutter, not data; without a width it takes an equal
+ * share of the table alongside the CSV's own columns.
+ */
+ th.field--line,
+ td.field--line {
+ width: 60px;
+ text-align: center;
+ }
+ }
+}
diff --git a/auth/config/email_types.php b/auth/config/email_types.php
index 4a4d41a4..70acd241 100644
--- a/auth/config/email_types.php
+++ b/auth/config/email_types.php
@@ -46,6 +46,28 @@
'can_unsubscribe' => false,
'factory' => Constants::MODULE_SLUG . '::EmailPasswordUpdated',
],
+ (object) [
+ 'slug' => 'user_import_complete',
+ 'name' => 'Auth: User Import Complete',
+ 'description' => 'Email sent to the administrator who requested a user import when it finishes.',
+ 'template_header' => '',
+ 'template_body' => 'auth/email/user_import_complete',
+ 'template_footer' => '',
+ 'default_subject' => 'Your user import has finished',
+ 'can_unsubscribe' => false,
+ 'factory' => Constants::MODULE_SLUG . '::EmailUserImportComplete',
+ ],
+ (object) [
+ 'slug' => 'user_import_failed',
+ 'name' => 'Auth: User Import Failed',
+ 'description' => 'Email sent to the administrator who requested a user import when it is rejected.',
+ 'template_header' => '',
+ 'template_body' => 'auth/email/user_import_failed',
+ 'template_footer' => '',
+ 'default_subject' => 'Your user import was rejected',
+ 'can_unsubscribe' => false,
+ 'factory' => Constants::MODULE_SLUG . '::EmailUserImportFailed',
+ ],
(object) [
'slug' => 'verify_email',
'name' => 'Auth: Verify Email (Generic)',
diff --git a/auth/views/email/user_import_complete.php b/auth/views/email/user_import_complete.php
new file mode 100644
index 00000000..d4652ea3
--- /dev/null
+++ b/auth/views/email/user_import_complete.php
@@ -0,0 +1,49 @@
+
+ The user import you requested has finished.
+
+
+
+
+
Rows in file
+
{{import.row_count}}
+
+
+
Accounts created
+
{{import.success}}
+
+
+
Rows with warnings
+
{{import.warnings}}
+
+
+
Rows with errors
+
{{import.errors}}
+
+
+
+{{#error}}
+
{{error}}
+{{/error}}
+{{#import.errors}}
+
+ Some rows could not be imported. The log records what happened to every row
+ in the file, so you can correct the failures and import them again.
+
+{{/import.errors}}
+{{#import.warnings}}
+
+ Some accounts were created, but something which had to happen afterwards did
+ not. They exist and can be signed in to; the log identifies them, and
+ whatever was meant to follow needs finishing by hand.
+
+{{/import.url}}
diff --git a/auth/views/email/user_import_complete_plaintext.php b/auth/views/email/user_import_complete_plaintext.php
new file mode 100644
index 00000000..77f68de3
--- /dev/null
+++ b/auth/views/email/user_import_complete_plaintext.php
@@ -0,0 +1,29 @@
+The user import you requested has finished.
+
+Rows in file: {{import.row_count}}
+Accounts created: {{import.success}}
+Rows with warnings: {{import.warnings}}
+Rows with errors: {{import.errors}}
+{{#error}}
+{{error}}
+{{/error}}
+
+{{#import.errors}}
+Some rows could not be imported. The log records what happened to every row in the file, so you can correct the failures and import them again.
+{{/import.errors}}
+
+{{#import.warnings}}
+Some accounts were created, but something which had to happen afterwards did not. They exist and can be signed in to; the log identifies them, and whatever was meant to follow needs finishing by hand.
+{{/import.warnings}}
+
+{{#log_url}}
+Download the log (you will need to be signed in to admin):
+
+{{log_url}}
+{{/log_url}}
+{{#import.url}}
+
+View this import in admin:
+
+{{import.url}}
+{{/import.url}}
diff --git a/auth/views/email/user_import_failed.php b/auth/views/email/user_import_failed.php
new file mode 100644
index 00000000..f6da73d9
--- /dev/null
+++ b/auth/views/email/user_import_failed.php
@@ -0,0 +1,29 @@
+
+ The user import you requested was rejected; no user accounts were created.
+
+{{#error}}
+
{{error}}
+{{/error}}
+{{#import.errors}}
+
+ The log records every row which could not be imported, and why. Correct them
+ in your CSV and upload it again.
+
+{{/import.errors}}
+{{#import.warnings}}
+
+ Some accounts were created before the import stopped, but something which had
+ to happen afterwards did not. They exist; the log identifies them, and
+ whatever was meant to follow needs finishing by hand.
+
+{{/import.url}}
diff --git a/auth/views/email/user_import_failed_plaintext.php b/auth/views/email/user_import_failed_plaintext.php
new file mode 100644
index 00000000..eebe63fe
--- /dev/null
+++ b/auth/views/email/user_import_failed_plaintext.php
@@ -0,0 +1,23 @@
+The user import you requested was rejected; no user accounts were created.
+
+{{error}}
+{{#import.errors}}
+
+The log records every row which could not be imported, and why. Correct them in your CSV and upload it again.
+{{/import.errors}}
+{{#import.warnings}}
+
+Some accounts were created before the import stopped, but something which had to happen afterwards did not. They exist; the log identifies them, and whatever was meant to follow needs finishing by hand.
+{{/import.warnings}}
+{{#log_url}}
+
+Download the log (you will need to be signed in to admin):
+
+{{log_url}}
+{{/log_url}}
+{{#import.url}}
+
+View this import in admin:
+
+{{import.url}}
+{{/import.url}}
diff --git a/composer.json b/composer.json
index 1ed8f7f2..0f19ac59 100644
--- a/composer.json
+++ b/composer.json
@@ -32,8 +32,10 @@
"php": ">=8.3",
"nails/common": "dev-develop",
"nails/module-admin": "dev-develop",
+ "nails/module-api": "dev-develop",
"nails/module-captcha": "dev-develop",
"nails/module-console": "dev-develop",
+ "nails/module-cron": "dev-develop",
"nails/module-email": "dev-develop",
"nails/module-form-builder": "dev-develop",
"hybridauth/hybridauth": "~3.0",
@@ -44,7 +46,11 @@
},
"require-dev": {
"phpunit/phpunit": "^12.0",
- "phpstan/phpstan": "^2.0"
+ "phpstan/phpstan": "^2.0",
+ "nails/module-queue": "dev-develop"
+ },
+ "suggest": {
+ "nails/module-queue": "Processes user imports on a long-running worker, rather than in chunks on the cron."
},
"scripts": {
"test": "./vendor/bin/phpunit",
@@ -55,6 +61,11 @@
"Nails\\Auth\\": "src/"
}
},
+ "autoload-dev": {
+ "psr-4": {
+ "Tests\\Auth\\": "tests/"
+ }
+ },
"extra": {
"nails": {
"moduleName": "auth",
diff --git a/services/services.php b/services/services.php
index 99ef2c95..e313bb46 100644
--- a/services/services.php
+++ b/services/services.php
@@ -42,6 +42,34 @@
return new Service\User\Import();
}
},
+ 'UserImportCsv' => function (): Service\User\Import\Csv {
+ if (class_exists('\App\Auth\Service\User\Import\Csv')) {
+ return new \App\Auth\Service\User\Import\Csv();
+ } else {
+ return new Service\User\Import\Csv();
+ }
+ },
+ 'UserImportDispatcher' => function (): Service\User\Import\Dispatcher {
+ if (class_exists('\App\Auth\Service\User\Import\Dispatcher')) {
+ return new \App\Auth\Service\User\Import\Dispatcher();
+ } else {
+ return new Service\User\Import\Dispatcher();
+ }
+ },
+ 'UserImportProcessor' => function (): Service\User\Import\Processor {
+ if (class_exists('\App\Auth\Service\User\Import\Processor')) {
+ return new \App\Auth\Service\User\Import\Processor();
+ } else {
+ return new Service\User\Import\Processor();
+ }
+ },
+ 'UserImportValidator' => function (): Service\User\Import\Validator {
+ if (class_exists('\App\Auth\Service\User\Import\Validator')) {
+ return new \App\Auth\Service\User\Import\Validator();
+ } else {
+ return new Service\User\Import\Validator();
+ }
+ },
'UserMeta' => function (): Service\User\Meta {
if (class_exists('\App\Auth\Service\User\Meta')) {
return new \App\Auth\Service\User\Meta();
@@ -93,6 +121,20 @@
return new Model\User\Group();
}
},
+ 'UserImport' => function (): Model\User\Import {
+ if (class_exists('\App\Auth\Model\User\Import')) {
+ return new \App\Auth\Model\User\Import();
+ } else {
+ return new Model\User\Import();
+ }
+ },
+ 'UserImportItem' => function (): Model\User\Import\Item {
+ if (class_exists('\App\Auth\Model\User\Import\Item')) {
+ return new \App\Auth\Model\User\Import\Item();
+ } else {
+ return new Model\User\Import\Item();
+ }
+ },
'UserPassword' => function (): Model\User\Password {
// @todo (Pablo 2025-07-15) - this should be a service
if (class_exists('\App\Auth\Model\User\Password')) {
@@ -145,6 +187,20 @@
return new Factory\Email\PasswordUpdated();
}
},
+ 'EmailUserImportComplete' => function (): Factory\Email\User\Import\Complete {
+ if (class_exists('\App\Auth\Factory\Email\User\Import\Complete')) {
+ return new \App\Auth\Factory\Email\User\Import\Complete();
+ } else {
+ return new Factory\Email\User\Import\Complete();
+ }
+ },
+ 'EmailUserImportFailed' => function (): Factory\Email\User\Import\Failed {
+ if (class_exists('\App\Auth\Factory\Email\User\Import\Failed')) {
+ return new \App\Auth\Factory\Email\User\Import\Failed();
+ } else {
+ return new Factory\Email\User\Import\Failed();
+ }
+ },
'EmailVerifyEmail' => function (): Factory\Email\VerifyEmail {
if (class_exists('\App\Auth\Factory\Email\VerifyEmail')) {
return new \App\Auth\Factory\Email\VerifyEmail();
@@ -204,6 +260,20 @@
return new Resource\User\Group($resource, $model);
}
},
+ 'UserImport' => function ($resource, $model): Resource\User\Import {
+ if (class_exists('\App\Auth\Resource\User\Import')) {
+ return new \App\Auth\Resource\User\Import($resource, $model);
+ } else {
+ return new Resource\User\Import($resource, $model);
+ }
+ },
+ 'UserImportItem' => function ($resource, $model): Resource\User\Import\Item {
+ if (class_exists('\App\Auth\Resource\User\Import\Item')) {
+ return new \App\Auth\Resource\User\Import\Item($resource, $model);
+ } else {
+ return new Resource\User\Import\Item($resource, $model);
+ }
+ },
'UserPasswordHistory' => function ($resource, $model): Resource\User\Password\History {
if (class_exists('\App\Auth\Resource\User\Password\History')) {
return new \App\Auth\Resource\User\Password\History($resource, $model);
diff --git a/src/Admin/Controller/Import.php b/src/Admin/Controller/Import.php
index 002eb085..377f5101 100644
--- a/src/Admin/Controller/Import.php
+++ b/src/Admin/Controller/Import.php
@@ -1,7 +1,15 @@
'import-user',
- 'is_hidden' => true,
- 'allowed_types' => 'csv',
- ];
+ /**
+ * The bucket the source CSV and its log are stored in
+ *
+ * @var array
+ */
+ const array IMPORT_BUCKET = Processor::IMPORT_BUCKET;
+
+ /**
+ * The permission required to work with imports
+ *
+ * @var string
+ */
+ const PERMISSION = Permission\Users\Create::class;
+
+ /**
+ * Where the controller lives
+ *
+ * @var string
+ */
+ const URL = 'admin/auth/import';
+
+ /**
+ * The number of per-line errors reported when an upload is rejected
+ *
+ * A wholly malformed file produces one per row; the first few say everything
+ * the thousandth does. Matches Processor::ERROR_SAMPLE_SIZE.
+ *
+ * @var int
+ */
+ const ERROR_SAMPLE_SIZE = Processor::ERROR_SAMPLE_SIZE;
+
+ /**
+ * How long the expiring CDN URL a download hands out is valid for, in seconds
+ *
+ * Shorter than module-admin's ADMIN_DATA_EXPORT_URL_TTL (300, see
+ * Nails\Admin\Service\DataExport::EXPORT_TTL) because the token only has to
+ * survive the 302 hop; a visitor who was not signed in re-enters log() after
+ * logging in and is issued a fresh one.
+ *
+ * @var int
+ */
+ const int URL_TTL = 60;
// --------------------------------------------------------------------------
@@ -62,63 +114,69 @@ public static function announce(): Nav|array|null
*
* @return void
* @throws FactoryException
+ * @throws ModelException
*/
public function index(): void
{
- if (!userHasPermission(Permission\Users\Create::class)) {
- unauthorised();
- }
-
- // --------------------------------------------------------------------------
+ $this->assertPermission();
+ $this->assertRunning();
/** @var Input $oInput */
$oInput = Factory::service('Input');
/** @var \Nails\Auth\Service\User\Import $oImportService */
$oImportService = Factory::service('UserImport', Constants::MODULE_SLUG);
- if ($oInput->post()) {
- try {
+ try {
+ /**
+ * On GET as well as POST: a template which cannot identify an account
+ * must not be offered for upload in the first place.
+ */
+ $this->assertTemplate();
- if ($oInput->post('action') === 'preview') {
- $this
- ->validateUpload()
- ->renderPreview(
- $this->uploadCsv()
- );
-
- return;
+ } catch (TemplateException $e) {
+ $this->oUserFeedback->error($this->escape($e->getMessage()));
+ $this->data['bTemplateUnusable'] = true;
+ }
- } elseif ($oInput->post('action') === 'import') {
- $this
- ->validateObject()
- ->processImport();
+ if ($oInput->post() && empty($this->data['bTemplateUnusable'])) {
+ try {
- } else {
- throw new \Exception('Unrecognised action');
- }
+ $this->handleUpload();
+ return;
} catch (ValidationException $e) {
- $this->oUserFeedback->error(
- sprintf(
- '%s:
%s
',
- $e->getMessage(),
+
+ /**
+ * Escaped on the way in: UserFeedback messages are rendered raw
+ * so that the markup below survives, and both the message and
+ * the itemised errors quote cell and header values lifted
+ * straight out of the uploaded CSV.
+ */
+ $sMessage = $this->escape($e->getMessage());
+ $aErrors = $e->getData() ?? [];
+
+ if (!empty($aErrors)) {
+ $sMessage .= sprintf(
+ ':