diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3cf9bd8..c5d8dae 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
# Registro de cambios
+## Sin publicar
+
+- Gestión de usuarios permite crear cuentas con el permiso `users.create` y enviar una invitación con plantilla para definir la contraseña y verificar la cuenta. Los enlaces caducan en 24 horas, se consumen al utilizarlos y mantienen la jerarquía de roles. `user-admin` incorpora `auth-ui` como dependencia para completar la activación.
+
## 4.4.1 — 2026-10-25
- La publicación programada rechaza modelos incompletos antes de consultar contenido y devuelve el error de configuración correspondiente.
diff --git a/docs/user-admin.md b/docs/user-admin.md
index 4309484..9ab4f1f 100644
--- a/docs/user-admin.md
+++ b/docs/user-admin.md
@@ -14,7 +14,7 @@ Abre `/admin/users` con el superadministrador inicial. Para permitir acceso a ot
return [
'gestor' => [
'admin' => ['access' => true],
- 'users' => ['view' => true, 'manage' => true],
+ 'users' => ['view' => true, 'manage' => true, 'create' => true],
],
];
```
@@ -24,6 +24,7 @@ Integra esa entrada con los roles que ya tenga el archivo, ejecuta `composer gfr
| Capacidad | Permite |
| --- | --- |
| `users.view` | Consultar, buscar, filtrar y paginar. |
+| `users.create` | Crear cuentas mediante invitación por correo. No se concede implícitamente con `users.manage`. |
| `users.manage` | Verificar, suspender, restablecer, eliminar y asignar roles. |
El superadministrador tiene acceso automáticamente. La comprobación del servidor se mantiene aunque un control esté oculto o deshabilitado.
@@ -42,7 +43,19 @@ Selecciona «Administrar» en una fila:
No permite modificar al superadministrador ni administrar la propia cuenta desde esta pantalla. Un administrador subordinado tampoco puede modificar cuentas con un rol que tenga `admin.access`.
-`disabled` corresponde a la desactivación voluntaria en Cuenta y seguridad; no se restablece desde esta pantalla. Crear cuentas o cambiar contraseñas administrativamente no son funciones incluidas.
+`disabled` corresponde a la desactivación voluntaria en Cuenta y seguridad; no se restablece desde esta pantalla. El administrador no elige ni conoce la contraseña de otras cuentas.
+
+## Añadir usuarios
+
+En Gestión de usuarios, pulsa **Añadir usuario**. Indica el correo electrónico y el rol; confirma con **Crear usuario y enviar enlace**. El superadministrador puede utilizarlo directamente. Para otro rol, habilita `users.create` junto a `users.view` y `admin.access` en su configuración de permisos y aplica la actualización de permisos del proyecto.
+
+La cuenta queda sin verificar hasta que su destinatario abra el enlace y defina una contraseña válida. Esa operación verifica la cuenta y consume el enlace, que caduca en 24 horas. No crea membresías de tenant. No se puede crear otro superadministrador; solo el superadministrador puede asignar roles con `admin.access`.
+
+Configura `APP_URL` con la URL pública absoluta del proyecto y el transporte de correo. Las invitaciones usan `mailTemplate` y el mismo servicio de correo de acceso que autenticación: cola persistente si está instalado `notifications-email`, o envío síncrono con timeout en caso contrario. El módulo depende de `auth-ui` para la pantalla pública de contraseña. Una confirmación de envío solicitado no garantiza recepción en la bandeja de entrada.
+
+Si el correo falla, la pantalla indica que la cuenta sí se creó; evita repetir el alta. El destinatario puede solicitar otro enlace desde **Recuperar contraseña**. Los reenvíos invalidan el enlace anterior y mantienen la activación mediante contraseña.
+
+Las rutas son `GET /admin/users/create` y `POST /ajax/admin/users/create`, protegidas por `auth` y `can:users.create`; la escritura requiere CSRF. La vista original es `user-admin/user-adminCreate.php`. Para repositorios de usuarios personalizados, la capacidad opcional `UserInvitationRepository` permite implementar persistencia compatible sin cambiar el contrato existente `UserAdministrationRepository`; el flujo público debe utilizar el mismo almacén de identidad.
### Por qué solo aparece «Usuario registrado»
@@ -127,11 +140,13 @@ Sus operaciones públicas principales son:
| `paginate($actorID, $page, $perPage, $search, $role, $status)` | Lista usuarios visibles para el actor |
| `assignableRoles($actorID)` | Devuelve los roles que pueden ofrecerse en la interfaz |
| `capabilities($actorID)` | Informa si el actor puede ver y administrar |
+| `creationForm($actorID)` | Devuelve los roles permitidos para crear cuentas |
+| `create($actorID, $email, $roleID)` | Crea una cuenta y solicita la entrega de su invitación |
| `assignRole($actorID, $userID, $roleID)` | Cambia el rol y revoca las sesiones del usuario |
| `moderate($actorID, $userID, $operation)` | Verifica, suspende, restaura o elimina |
| `setActive($actorID, $userID, $active)` | Activa o desactiva desde integraciones que usen este contrato |
-`users.manage` permite las escrituras. Para lectura basta `users.view` o `users.manage`. El superadministrador supera ambas comprobaciones mediante su rol del sistema.
+`users.manage` permite moderar cuentas y asignar roles; crear cuentas requiere `users.create`. Para lectura basta `users.view` o `users.manage` en el servicio; la ruta del listado requiere `users.view`. El superadministrador supera estas comprobaciones mediante su rol del sistema.
### Filtros y paginación
diff --git a/maintenance/mejoras-pendientes.md b/maintenance/mejoras-pendientes.md
index 1b2f311..a3cb2c4 100644
--- a/maintenance/mejoras-pendientes.md
+++ b/maintenance/mejoras-pendientes.md
@@ -4,7 +4,7 @@ Esta lista recoge mejoras opcionales de GFrame que no bloquean las correcciones
## Administración de usuarios
-- [ ] **Añadir usuarios desde el admin.** Prioridad baja; pendiente, sin implementar. Incorporar «Añadir usuario» dentro de Gestión de usuarios, con una vista propia y un permiso específico. Mantenerlo separado del registro público y de Mi cuenta. Antes de implementarlo, acordar cómo se entregará el acceso inicial y cómo se verificará la cuenta. Respetar la jerarquía de roles y los contratos de autenticación existentes.
+- [x] **Añadir usuarios desde el admin.** Implementado con una vista propia en Gestión de usuarios, permiso `users.create` e invitación por correo para definir la contraseña y verificarla, según la decisión del propietario. Mantiene la jerarquía de roles, caducidad y consumo único del enlace, recuperación/reenvío y distinción entre alta completada y fallo del correo. Pruebas con cuentas temporales y transporte de correo simulado; formulario comprobado en navegador. Véase [la guía](../docs/user-admin.md).
## Campañas
@@ -18,7 +18,7 @@ Esta lista recoge mejoras opcionales de GFrame que no bloquean las correcciones
## Redirecciones
-- [ ] **Módulo opcional independiente de redirecciones.** Aplazado por el usuario el 19 de octubre de 2026; no es necesario ahora. Contemplar direcciones anteriores al cambiar el slug, destinos locales y prevención de ciclos y cadenas, respetando rutas y permisos de cada proyecto. Pendiente de diseño y autorización para implementar.
+- **Módulo de redirecciones descartado por el propietario.** Se retira de las mejoras previstas; no implementar.
## Auth: aviso administrativo de registro
diff --git a/resources/modules/auth-ui/application/app/controllers/auth-ui/AuthController.php b/resources/modules/auth-ui/application/app/controllers/auth-ui/AuthController.php
index 35c6488..912148c 100644
--- a/resources/modules/auth-ui/application/app/controllers/auth-ui/AuthController.php
+++ b/resources/modules/auth-ui/application/app/controllers/auth-ui/AuthController.php
@@ -134,10 +134,11 @@ public function resendVerification(): array
$response = $this->authModel->verifyAcount($email);
$token = (string)($response['data']['token'] ?? '');
unset($response['data']['token']);
+ $invitation = str_starts_with($token, 'invite_');
if (($response['status'] ?? '') === 'success' && $token !== '' && !$this->sendAccessMail(
$email, 'Verifica tu cuenta', 'Verificar mi cuenta',
- '/login/verify?v=' . rawurlencode($token),
- 'Confirma tu correo electrónico para activar la cuenta.'
+ ($invitation ? '/login/resetpassword?rp=' : '/login/verify?v=') . rawurlencode($token),
+ $invitation ? 'Define tu contraseña para verificar y activar la cuenta.' : 'Confirma tu correo electrónico para activar la cuenta.'
)) return $this->withMessage(['status' => 'error', 'code' => 'mail_delivery_failed']);
return $this->withMessage($response);
}
diff --git a/resources/modules/user-admin/application/app/controllers/user-admin/UserAdminController.php b/resources/modules/user-admin/application/app/controllers/user-admin/UserAdminController.php
index 129cd69..9fc6dc3 100644
--- a/resources/modules/user-admin/application/app/controllers/user-admin/UserAdminController.php
+++ b/resources/modules/user-admin/application/app/controllers/user-admin/UserAdminController.php
@@ -37,6 +37,7 @@ public function index(): array
'users' => $users,
'roles' => (array)($roles['data'] ?? []),
'can_manage' => !empty($capabilities['data']['manage']),
+ 'can_create' => !empty($capabilities['data']['create']),
],
]);
}
@@ -62,6 +63,21 @@ public function list(): array
return $response;
}
+ public function create(): array
+ {
+ return $this->withMessage($this->users->creationForm($this->actorID()));
+ }
+
+ public function store(): array
+ {
+ $email = trim((string)($_POST['email'] ?? ''));
+ $roleID = (int)($_POST['role_id'] ?? 0);
+ if ($email === '' || $roleID <= 0) {
+ return ['status' => 'error', 'code' => 'empty_field', 'message' => 'Completa el correo electrónico y el rol.'];
+ }
+ return $this->withMessage($this->users->create($this->actorID(), $email, $roleID));
+ }
+
public function update(): array
{
$actorID = $this->actorID();
@@ -108,6 +124,13 @@ protected function actorID(): int
protected function withMessage(array $response): array
{
$messages = [
+ 'user_invited' => 'Cuenta creada. Se ha solicitado el envío del enlace para definir la contraseña y verificarla.',
+ 'user_created_mail_failed' => 'La cuenta se creó, pero no se pudo enviar el correo. El usuario puede solicitar otro enlace desde Recuperar contraseña.',
+ 'user_exists' => 'Ya existe una cuenta con ese correo electrónico.',
+ 'invalid_email' => 'Escribe un correo electrónico válido.',
+ 'invitation_url_missing' => 'Configura APP_URL para enviar el enlace de activación.',
+ 'creation_not_supported' => 'El repositorio de la aplicación no permite crear usuarios mediante invitación.',
+ 'user_creation_failed' => 'No se pudo crear la cuenta.',
'user_verified' => 'Cuenta verificada.',
'user_suspended' => 'Cuenta suspendida.',
'user_restored' => 'Acceso restablecido.',
diff --git a/resources/modules/user-admin/application/app/views/user-admin/user-adminCreate.meta.php b/resources/modules/user-admin/application/app/views/user-admin/user-adminCreate.meta.php
new file mode 100644
index 0000000..ea09fd2
--- /dev/null
+++ b/resources/modules/user-admin/application/app/views/user-admin/user-adminCreate.meta.php
@@ -0,0 +1,6 @@
+ ['title' => 'Añadir usuario - ' . site_name],
+ 'js' => ['public/js/modules/user-admin/user-create.js'],
+];
diff --git a/resources/modules/user-admin/application/app/views/user-admin/user-adminCreate.php b/resources/modules/user-admin/application/app/views/user-admin/user-adminCreate.php
new file mode 100644
index 0000000..345cace
--- /dev/null
+++ b/resources/modules/user-admin/application/app/views/user-admin/user-adminCreate.php
@@ -0,0 +1,33 @@
+ htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
+?>
+
diff --git a/resources/modules/user-admin/application/routes/routes_admin_users.php b/resources/modules/user-admin/application/routes/routes_admin_users.php
index 72d4b63..8137e73 100644
--- a/resources/modules/user-admin/application/routes/routes_admin_users.php
+++ b/resources/modules/user-admin/application/routes/routes_admin_users.php
@@ -7,3 +7,9 @@
->template('admin')
->middleware(['auth', 'can:users.view'])
->registerFinal();
+
+Route::get('admin/users/create', 'user-admin/UserAdminController@create')
+ ->module('user-admin')
+ ->template('admin')
+ ->middleware(['auth', 'can:users.create'])
+ ->registerFinal();
diff --git a/resources/modules/user-admin/application/routes/routes_ajax_admin_users.php b/resources/modules/user-admin/application/routes/routes_ajax_admin_users.php
index 961f3c1..cbaa6d6 100644
--- a/resources/modules/user-admin/application/routes/routes_ajax_admin_users.php
+++ b/resources/modules/user-admin/application/routes/routes_ajax_admin_users.php
@@ -2,6 +2,11 @@
use RouteBuilder as Route;
+Route::post('ajax/admin/users/create', 'user-admin/UserAdminController@store')
+ ->module('user-admin')
+ ->middleware(['auth', 'can:users.create'])
+ ->registerFinal();
+
Route::post('ajax/admin/users/list', 'user-admin/UserAdminController@list')
->module('user-admin')
->middleware(['auth', 'can:users.view'])
diff --git a/resources/modules/user-admin/javascript/user-create.js b/resources/modules/user-admin/javascript/user-create.js
new file mode 100644
index 0000000..8192b8f
--- /dev/null
+++ b/resources/modules/user-admin/javascript/user-create.js
@@ -0,0 +1,33 @@
+(function ($) {
+ 'use strict';
+ const form = $('#user-create-form');
+ let saving = false;
+ form.on('submit', function (event) {
+ event.preventDefault();
+ if (saving) return;
+ form.addClass('was-validated');
+ if (!this.checkValidity()) return;
+ saving = true;
+ const button = form.find('[type="submit"]').prop('disabled', true);
+ $.ajax({url: form.data('save-url'), method: 'POST', dataType: 'json', data: form.serialize()})
+ .done(function (response) {
+ const created = response.status === 'success';
+ const warning = response.code === 'user_created_mail_failed';
+ alertToast({icon: created ? (warning ? 'warning' : 'success') : 'error', title: response.message});
+ $('#user-create-result').prop('hidden', false)
+ .removeClass('alert-success alert-warning alert-danger')
+ .addClass(created ? (warning ? 'alert-warning' : 'alert-success') : 'alert-danger')
+ .text(response.message);
+ if (created) {
+ form.find('input, select').prop('disabled', true);
+ } else {
+ saving = false;
+ button.prop('disabled', false);
+ }
+ }).fail(function (xhr, status, error) {
+ saving = false;
+ button.prop('disabled', false);
+ alertToast(Object.assign({icon: 'error'}, ajaxError(status, error)));
+ });
+ });
+})(jQuery);
diff --git a/resources/modules/user-admin/module.php b/resources/modules/user-admin/module.php
index a42cbec..645c5ca 100644
--- a/resources/modules/user-admin/module.php
+++ b/resources/modules/user-admin/module.php
@@ -4,7 +4,7 @@
'name' => 'user-admin',
'type' => 'backend-module',
'description' => 'Administración de usuarios y asignación protegida de roles.',
- 'dependencies' => ['admin-panel', 'alerts', 'frontend-core'],
+ 'dependencies' => ['admin-panel', 'alerts', 'frontend-core', 'auth-ui'],
'requires_schema' => ['auth'],
'runtime' => ['root' => 'application/app', 'namespace' => 'GFrame\\Modules\\UserAdmin'],
'assets' => [
diff --git a/skills/gframe-auth-access/references/authentication-contracts.md b/skills/gframe-auth-access/references/authentication-contracts.md
index 11bbc0b..a48dc58 100644
--- a/skills/gframe-auth-access/references/authentication-contracts.md
+++ b/skills/gframe-auth-access/references/authentication-contracts.md
@@ -38,6 +38,8 @@ Auth registration and installation never create tenant memberships. The applicat
The modules `auth-ui`, `self-account` and `user-admin` provide the standard controllers, routes and views. Applications may override their presentation and keep project-specific messages, email templates, redirects, area assignments and legacy session keys at the application boundary.
+Administrative creation uses `UserAdministrationService::create`, the separate `users.create` permission and optional `UserInvitationRepository` capability on `UserModel`. The administrator supplies email and an assignable role, never the password. The account stays `unverify` until the recipient sets a password through the existing reset screen using an `invite_` token. Reset consumes that token conditionally with activation; ordinary verification must reject invitation tokens. Recovery/resend preserve invitation semantics. Access mail uses `mailTemplate` through `AccessMailService`; distinguish a created account with failed mail from failed creation and never expose the token in the admin response. `user-admin` depends on `auth-ui` for the public completion flow. No tenant membership is created.
+
The normalized `$_SESSION['auth']` identity is the framework source of truth and contains `id`, `email`, `name`, `role_id`, and `role`. `name` may come from an application profile and is not required in the framework users table. Do not add a duplicate superadministrator boolean flag.
When extracting an application's Auth UI, preserve its HTTP route names and posted field names. If a rename is unavoidable, document the old-to-new mapping, update every view and JavaScript caller, keep a compatibility alias where needed, and test the complete browser-to-controller flow before release. Never leave copied JavaScript pointing at an unpublished route.
diff --git a/src/GFrame/Auth/AuthModel.php b/src/GFrame/Auth/AuthModel.php
index 6169fd4..1de2650 100644
--- a/src/GFrame/Auth/AuthModel.php
+++ b/src/GFrame/Auth/AuthModel.php
@@ -94,6 +94,7 @@ public function registerAcount(string $email, string $password): array
public function validateAcount(string $token): array
{
+ if (str_starts_with(strtolower(trim($token)), 'invite_')) return $this->error('invalid_token');
try {
$user = $this->findByToken(trim($token));
if ($user === null || !$this->tokens->isValidTimestamp((string)($user['token_updated_at'] ?? ''))) {
@@ -123,7 +124,7 @@ public function recoveryAcount(string $email): array
return ['status' => 'success', 'code' => 'recovery_requested'];
}
- $token = $this->tokens->issue();
+ $token = (str_starts_with((string)($user['token'] ?? ''), 'invite_') ? 'invite_' : '') . $this->tokens->issue();
$this->updateAuthUser((int)$user['user_id'], [
'token' => $token,
'token_updated_at' => $this->tokens->issuedAt(),
@@ -150,13 +151,25 @@ public function resetPassword(string $token, string $password): array
return $this->error('suspended_account');
}
- $this->updateAuthUser((int)$user['user_id'], [
+ $attributes = [
'password' => $this->passwords->hash($password),
'token' => $this->tokens->issue(),
'token_updated_at' => $this->tokens->issuedAt(),
'password_changed_at' => date('Y-m-d H:i:s'),
'force_password_change' => false,
- ]);
+ ];
+ if (str_starts_with(strtolower(trim($token)), 'invite_')) {
+ // Consumir la invitación una sola vez, junto con contraseña y verificación.
+ $attributes['status'] = $this->activeStatus;
+ $result = $this->reset()->useStrictComparison(false)
+ ->where('user_id', '=', (int)$user['user_id'])
+ ->where('status', '=', (string)$user['status'])
+ ->where('force_password_change', '=', 1)
+ ->whereExact('token', trim($token))->update($attributes);
+ if (($result['status'] ?? '') !== 'updated') return $this->error('invalid_token');
+ } else {
+ $this->updateAuthUser((int)$user['user_id'], $attributes);
+ }
($this->sessions ?? \GFrame\Session\SessionRuntime::registry())?->revokeUser((int)$user['user_id']);
return ['status' => 'success', 'code' => 'password_reset'];
@@ -227,7 +240,7 @@ public function verifyAcount(string $email): array
return $this->error('already_verified');
}
- $token = $this->tokens->issue();
+ $token = (str_starts_with((string)($user['token'] ?? ''), 'invite_') ? 'invite_' : '') . $this->tokens->issue();
$this->updateAuthUser((int)$user['user_id'], [
'token' => $token,
'token_updated_at' => $this->tokens->issuedAt(),
diff --git a/src/GFrame/Auth/Contracts/UserInvitationRepository.php b/src/GFrame/Auth/Contracts/UserInvitationRepository.php
new file mode 100644
index 0000000..4a70356
--- /dev/null
+++ b/src/GFrame/Auth/Contracts/UserInvitationRepository.php
@@ -0,0 +1,10 @@
+sessions = $sessions ?? SessionRuntime::registry();
}
@@ -65,13 +66,70 @@ public function capabilities(int $actorID): array
return [
'status' => 'success',
'code' => 'capabilities_loaded',
- 'data' => ['view' => true, 'manage' => $this->canManageUsers($actorID)],
+ 'data' => ['view' => true, 'manage' => $this->canManageUsers($actorID), 'create' => $this->hasPermission($actorID, 'users.create')],
];
} catch (Exception $exception) {
return $this->failure($exception, 'capabilities_lookup_failed');
}
}
+ public function creationForm(int $actorID): array
+ {
+ try {
+ if (!$this->hasPermission($actorID, 'users.create')) return $this->denied();
+ $roles = array_values(array_filter($this->roles->assignableRoles(), fn(array $role): bool =>
+ !$this->isSuperadministratorRole($role)
+ && ($this->actorIsSuperadministrator($actorID) || !$this->isAdministratorRole($role))
+ ));
+ return ['status' => 'success', 'code' => 'roles_loaded', 'data' => ['roles' => $roles]];
+ } catch (Exception $exception) {
+ return $this->failure($exception, 'roles_list_failed');
+ }
+ }
+
+ public function create(int $actorID, string $email, int $roleID): array
+ {
+ try {
+ if (!$this->hasPermission($actorID, 'users.create')) return $this->denied();
+ if (!$this->users instanceof \GFrame\Auth\Contracts\UserInvitationRepository) return $this->error('creation_not_supported');
+ $email = mb_strtolower(trim($email), 'UTF-8');
+ if (strlen($email) > 190 || !filter_var($email, FILTER_VALIDATE_EMAIL)) return $this->error('invalid_email');
+ $role = $this->roles->findRoleByID($roleID);
+ if ($role === null || $this->isSuperadministratorRole($role)
+ || (!$this->actorIsSuperadministrator($actorID) && $this->isAdministratorRole($role))) {
+ return $this->error('invalid_role_assignment');
+ }
+ $base = \UrlHelper::canonicalBase();
+ if ($base === null) return $this->error('invitation_url_missing');
+ if ($this->users->invitationEmailExists($email)) return $this->error('user_exists');
+ $tokens = new TokenManager();
+ $token = 'invite_' . $tokens->issue();
+ $userID = $this->users->createInvitedUser($email, $roleID, $token, $tokens->issuedAt());
+ if ($userID <= 0) return $this->error('user_creation_failed');
+ try {
+ $result = ($this->accessMail ?? new \GFrame\Mail\AccessMailService())->send(
+ $email, 'Activa tu cuenta', [
+ 'title' => 'Activa tu cuenta', 'h1' => 'Activa tu cuenta', 'greeting' => 'Hola.',
+ 'p1' => 'Un administrador ha creado tu cuenta. Define tu contraseña para verificarla y acceder.',
+ 'aHref' => $base . 'login/resetpassword?rp=' . rawurlencode($token),
+ 'aText' => 'Definir contraseña y activar cuenta',
+ 'p2' => 'El enlace caduca en 24 horas. Si vence, solicita otro desde Recuperar contraseña.',
+ ], '', $token
+ );
+ } catch (Exception $exception) {
+ error_log('[GFrame User Admin] No se pudo enviar la invitación.');
+ $result = ['status' => 'error'];
+ }
+ return [
+ 'status' => 'success',
+ 'code' => ($result['status'] ?? '') === 'success' ? 'user_invited' : 'user_created_mail_failed',
+ 'data' => ['user_id' => $userID],
+ ];
+ } catch (Exception $exception) {
+ return $this->failure($exception, 'user_creation_failed');
+ }
+ }
+
public function setActive(int $actorID, int $userID, bool $active): array
{
try {
diff --git a/src/GFrame/Auth/UserModel.php b/src/GFrame/Auth/UserModel.php
index 08ada9e..5713b22 100644
--- a/src/GFrame/Auth/UserModel.php
+++ b/src/GFrame/Auth/UserModel.php
@@ -8,7 +8,7 @@
use GFrame\Auth\Contracts\UserModerationRepository;
use RuntimeException;
-class UserModel extends \ORM implements SelfAccountRepository, AccountDeactivationPolicy, UserAdministrationRepository, UserModerationRepository
+class UserModel extends \ORM implements SelfAccountRepository, AccountDeactivationPolicy, UserAdministrationRepository, UserModerationRepository, \GFrame\Auth\Contracts\UserInvitationRepository
{
protected $table = 'users';
protected $primaryKey = 'user_id';
@@ -119,6 +119,26 @@ public function createActiveUser(string $email, string $passwordHash, int $roleI
]))->insert();
}
+ public function invitationEmailExists(string $email): bool
+ {
+ return $this->reset()->where('email', '=', $email)->exists();
+ }
+
+ public function createInvitedUser(string $email, int $roleID, string $token, string $issuedAt): int
+ {
+ $model = new static([
+ 'email' => $email,
+ 'password' => password_hash(bin2hex(random_bytes(32)), PASSWORD_BCRYPT),
+ 'role_id' => $roleID,
+ 'status' => 'unverify',
+ 'token' => $token,
+ 'token_updated_at' => $issuedAt,
+ 'force_password_change' => true,
+ ]);
+ $model->onConnection($this->resolveCurrentConnectionName());
+ return (int)$model->insert();
+ }
+
public function paginateUsers(int $page, int $perPage, string $search = '', string $role = '', string $status = ''): array
{
$countQuery = $this->reset();
diff --git a/tests/UserAdminUiTest.php b/tests/UserAdminUiTest.php
index 5c03a5b..d036a99 100644
--- a/tests/UserAdminUiTest.php
+++ b/tests/UserAdminUiTest.php
@@ -30,6 +30,7 @@ public function testManifestDoesNotIncludeUnusedSelectorDependency(): void
self::assertNotContains('gf-select', $manifest['dependencies']);
self::assertNotContains('self-account', $manifest['dependencies']);
self::assertContains('admin-panel', $manifest['dependencies']);
+ self::assertContains('auth-ui', $manifest['dependencies']);
$menu = (string)file_get_contents($this->modulePath . '/application/admin/users-menu.php');
self::assertStringContainsString('Gestión de usuarios', $menu);
self::assertStringContainsString('
Usuarios', $menu);
@@ -43,6 +44,8 @@ public function testRoutesSeparateViewAndManagementPermissions(): void
self::assertStringContainsString("can:users.view", $web);
self::assertStringContainsString("can:users.view", $ajax);
self::assertStringContainsString("can:users.manage", $ajax);
+ self::assertStringContainsString("can:users.create", $web);
+ self::assertStringContainsString("can:users.create", $ajax);
self::assertStringNotContainsString('role:superadministrator', $web . $ajax);
}
diff --git a/tests/UserInvitationTest.php b/tests/UserInvitationTest.php
new file mode 100644
index 0000000..c518a0d
--- /dev/null
+++ b/tests/UserInvitationTest.php
@@ -0,0 +1,179 @@
+ ['driver' => 'sqlite', 'path' => ':memory:']]);
+ \ORM::disconnect();
+ $this->pdo = \DatabaseManager::connection('invitation');
+ $this->pdo->exec(file_get_contents(dirname(__DIR__) . '/resources/database/schema/sqlite/auth.sql'));
+ $this->pdo->exec("INSERT INTO roles(role_id,name,slug,permissions_json) VALUES
+ (3,'Creador','creator','{\"users.create\":true,\"users.view\":true}'),
+ (4,'Administrador','administrator','{\"admin.access\":true}'),
+ (5,'Gestor','manager','{\"users.manage\":true}')");
+ $this->pdo->exec("INSERT INTO users(user_id,email,password,role_id,status) VALUES
+ (1,'root@example.test','hash',1,'verify'),(2,'creator@example.test','hash',3,'verify'),
+ (3,'manager@example.test','hash',5,'verify')");
+ ConfigRepository::replace(['app' => ['url' => 'https://example.test/subdir/']]);
+ $this->mail = new InvitationMailSender();
+ $this->service = new UserAdministrationService(new UserModel(), new RoleModel(), accessMail: new AccessMailService($this->mail));
+ }
+
+ protected function tearDown(): void
+ {
+ \ORM::disconnect();
+ ConfigRepository::replace([]);
+ }
+
+ public function testInvitationDefinesPasswordVerifiesAndCannotBeReused(): void
+ {
+ $result = $this->service->create(1, ' Invited@Example.test ', 2);
+ self::assertSame('user_invited', $result['code']);
+ self::assertArrayNotHasKey('token', $result['data']);
+ $auth = new AuthModel();
+ $user = $auth->findByEmail('invited@example.test');
+ self::assertSame('unverify', $user['status']);
+ self::assertCount(1, $this->mail->messages);
+ self::assertSame('mailTemplate', $this->mail->messages[0]['template']);
+ self::assertStringStartsWith('https://example.test/subdir/login/resetpassword?rp=invite_', $this->mail->messages[0]['variables']['aHref']);
+ $token = $user['token'];
+ self::assertSame('invalid_token', $auth->validateAcount($token)['code']);
+ self::assertSame('invalid_token', $auth->validateAcount(strtoupper($token))['code']);
+ self::assertSame('invalid_password', $auth->resetPassword($token, 'short')['code']);
+ $password = ' Exact-password-123 ';
+ self::assertSame('password_reset', $auth->resetPassword($token, $password)['code']);
+ self::assertSame('verify', $auth->findByEmail('invited@example.test')['status']);
+ self::assertSame('success', $auth->login('invited@example.test', $password)['status']);
+ self::assertSame('invalid_token', $auth->resetPassword($token, 'Other-password-123')['code']);
+ self::assertSame(0, (int)$this->pdo->query('SELECT count(*) FROM tenant_memberships')->fetchColumn());
+ }
+
+ public function testPermissionHierarchyDuplicatesAndValidationPreventWrites(): void
+ {
+ self::assertSame('forbidden', $this->service->create(3, 'new@example.test', 2)['code']);
+ self::assertSame('forbidden', $this->service->create(0, 'new@example.test', 2)['code']);
+ self::assertSame('invalid_role_assignment', $this->service->create(1, 'new@example.test', 1)['code']);
+ self::assertSame('invalid_role_assignment', $this->service->create(2, 'new@example.test', 4)['code']);
+ self::assertSame('invalid_role_assignment', $this->service->create(1, 'new@example.test', 99)['code']);
+ self::assertSame('invalid_email', $this->service->create(1, 'broken', 2)['code']);
+ self::assertSame('user_exists', $this->service->create(1, 'ROOT@example.test', 2)['code']);
+ self::assertSame(3, (int)$this->pdo->query('SELECT count(*) FROM users')->fetchColumn());
+ self::assertSame([], $this->mail->messages);
+ $roleIDs = array_map('intval', array_column($this->service->creationForm(2)['data']['roles'], 'role_id'));
+ sort($roleIDs);
+ self::assertSame([2, 3, 5], $roleIDs);
+ self::assertSame('user_invited', $this->service->create(1, 'admin@example.test', 4)['code']);
+ }
+
+ public function testExpiryBlockedAccountsAndReplacementTokens(): void
+ {
+ $this->service->create(2, 'invited@example.test', 2);
+ $auth = new AuthModel();
+ $user = $auth->findByEmail('invited@example.test');
+ $oldToken = $user['token'];
+ $this->pdo->exec("UPDATE users SET token_updated_at='2000-01-01 00:00:00' WHERE user_id=" . (int)$user['user_id']);
+ self::assertSame('invalid_token', $auth->resetPassword($oldToken, 'Password-123')['code']);
+ $recovery = $auth->recoveryAcount('invited@example.test');
+ $token = $recovery['data']['token'];
+ self::assertStringStartsWith('invite_', $token);
+ self::assertSame('invalid_token', $auth->resetPassword($oldToken, 'Password-123')['code']);
+ $resend = $auth->verifyAcount('invited@example.test');
+ self::assertStringStartsWith('invite_', $resend['data']['token']);
+ self::assertSame('invalid_token', $auth->resetPassword($token, 'Password-123')['code']);
+ $this->pdo->exec("UPDATE users SET status='suspended' WHERE user_id=" . (int)$user['user_id']);
+ self::assertSame('suspended_account', $auth->resetPassword($resend['data']['token'], 'Password-123')['code']);
+ }
+
+ public function testMailFailureDoesNotLoseTheCreatedAccountOrExposeToken(): void
+ {
+ $this->mail->fail = true;
+ $result = $this->service->create(1, 'pending@example.test', 2);
+ self::assertSame('success', $result['status']);
+ self::assertSame('user_created_mail_failed', $result['code']);
+ self::assertSame(['user_id'], array_keys($result['data']));
+ self::assertSame('unverify', (new AuthModel())->findByEmail('pending@example.test')['status']);
+ self::assertSame('user_exists', $this->service->create(1, 'pending@example.test', 2)['code']);
+ }
+
+ public function testMissingCanonicalUrlDoesNotCreateAnAccount(): void
+ {
+ ConfigRepository::replace(['app' => ['url' => '/relative']]);
+ self::assertSame('invitation_url_missing', $this->service->create(1, 'pending@example.test', 2)['code']);
+ self::assertNull((new AuthModel())->findByEmail('pending@example.test'));
+ self::assertSame([], $this->mail->messages);
+ }
+
+ public function testAdministrativeVerificationDoesNotStrandTheInvitation(): void
+ {
+ $created = $this->service->create(1, 'verified@example.test', 2);
+ $auth = new AuthModel();
+ $token = $auth->findByEmail('verified@example.test')['token'];
+ self::assertSame('user_verified', $this->service->moderate(1, $created['data']['user_id'], 'verify')['code']);
+ self::assertSame('password_reset', $auth->resetPassword($token, 'Password-123')['code']);
+ self::assertSame('success', $auth->login('verified@example.test', 'Password-123')['status']);
+ }
+
+ public function testInvitationCanBeQueuedAndSupersededMailIsRejected(): void
+ {
+ $this->pdo->exec(file_get_contents(dirname(__DIR__) . '/resources/modules/notifications/database/sqlite.sql'));
+ $queue = new \GFrame\Notifications\NotificationQueueModel();
+ $service = new UserAdministrationService(new UserModel(), new RoleModel(), accessMail: new AccessMailService($this->mail, $queue));
+ self::assertSame('user_invited', $service->create(1, 'queued@example.test', 2)['code']);
+ self::assertSame([], $this->mail->messages);
+ $record = $this->pdo->query('SELECT * FROM notification_queue')->fetch(\PDO::FETCH_ASSOC);
+ $record['payload'] = json_decode($record['payload_json'], true);
+ $transport = new \GFrame\Notifications\Email\EmailNotificationTransport($this->mail);
+ $transport->send($record);
+ self::assertCount(1, $this->mail->messages);
+ (new AuthModel())->recoveryAcount('queued@example.test');
+ $this->expectException(\GFrame\Mail\ExpiredAccessMail::class);
+ $transport->send($record);
+ }
+
+ public function testControllerUsesSessionActorAndKeepsTokensPrivate(): void
+ {
+ require_once dirname(__DIR__) . '/resources/modules/user-admin/application/app/controllers/user-admin/UserAdminController.php';
+ $controller = new \GFrame\Modules\UserAdmin\Controllers\UserAdminController($this->service, new RoleModel());
+ $_SESSION['auth']['id'] = 3;
+ $_POST = ['email' => 'boundary@example.test', 'role_id' => '2', 'actor_id' => '1'];
+ self::assertSame('forbidden', $controller->store()['code']);
+ $_SESSION['auth']['id'] = 1;
+ $result = $controller->store();
+ self::assertSame('user_invited', $result['code']);
+ self::assertNotEmpty($result['message']);
+ self::assertSame(['user_id'], array_keys($result['data']));
+ $_SESSION = [];
+ $_POST = [];
+ }
+}
+
+final class InvitationMailSender implements MailSender
+{
+ public array $messages = [];
+ public bool $fail = false;
+ public function sendTemplate(string $recipient, string $subject, string $template, array $variables = [], array $options = []): array
+ {
+ $this->messages[] = compact('recipient', 'subject', 'template', 'variables', 'options');
+ return ['status' => $this->fail ? 'error' : 'success'];
+ }
+ public function sendHtml(string $recipient, string $subject, string $html, array $options = []): array
+ {
+ throw new \LogicException('Las invitaciones deben usar plantillas.');
+ }
+}
diff --git a/tests/js/user-create.browser.test.cjs b/tests/js/user-create.browser.test.cjs
new file mode 100644
index 0000000..38ea113
--- /dev/null
+++ b/tests/js/user-create.browser.test.cjs
@@ -0,0 +1,66 @@
+const assert = require('node:assert/strict');
+const {test} = require('node:test');
+const {spawnSync} = require('node:child_process');
+const {join} = require('node:path');
+
+test('Alta administrativa valida, incluye CSRF y evita duplicados; funciona en móvil y ambos temas', {
+ skip: !process.env.GFRAME_TEST_BROWSER
+}, async () => {
+ const {chromium} = require('playwright');
+ const root = join(__dirname, '../..');
+ const rendered = spawnSync(process.env.GFRAME_TEST_PHP || 'php', ['-r',
+ 'require "packages/autoload.php"; session_start(); define("site_url", "https://example.test/"); $_SESSION=["csrfToken"=>"test-token","csrfTimestamp"=>time()]; $data=["data"=>["roles"=>[["role_id"=>2,"name"=>"Usuario"]]]]; include "resources/modules/user-admin/application/app/views/user-admin/user-adminCreate.php"; session_destroy();'
+ ], {cwd: root, encoding: 'utf8'});
+ assert.equal(rendered.status, 0, rendered.stderr);
+ const browser = await chromium.launch({headless: true,
+ ...(process.env.GFRAME_TEST_BROWSER !== '1' ? {executablePath: process.env.GFRAME_TEST_BROWSER} : {})});
+ try {
+ const page = await browser.newPage();
+ await page.setContent(rendered.stdout);
+ await page.addStyleTag({path: join(root, 'resources/modules/bootstrap/public/css/bootstrap.min.css')});
+ await page.addScriptTag({path: join(root, 'resources/modules/jquery/public/jquery.min.js')});
+ await page.evaluate(() => {
+ window.requests = [];
+ window.alertToast = () => {};
+ window.ajaxError = () => ({title: 'Error de transporte'});
+ $.ajax = options => {
+ const deferred = $.Deferred();
+ window.requests.push({options, deferred});
+ return deferred.promise();
+ };
+ });
+ await page.addScriptTag({path: join(root, 'resources/modules/user-admin/javascript/user-create.js')});
+ await page.locator('button[type=submit]').click();
+ assert.equal(await page.evaluate(() => window.requests.length), 0);
+ await page.locator('#user-create-email').fill('persona@example.test');
+ await page.locator('#user-create-role').selectOption('2');
+ await page.locator('#user-create-email').press('Enter');
+ assert.equal(await page.evaluate(() => window.requests.length), 1);
+ const request = await page.evaluate(() => window.requests[0].options);
+ assert.equal(request.url, 'https://example.test/ajax/admin/users/create');
+ assert.equal(new URLSearchParams(request.data).get('csrfToken'), 'test-token');
+ assert.equal(new URLSearchParams(request.data).get('email'), 'persona@example.test');
+ await page.evaluate(() => $('#user-create-form').trigger('submit'));
+ assert.equal(await page.evaluate(() => window.requests.length), 1);
+ await page.evaluate(() => window.requests[0].deferred.resolve({status: 'error', code: 'user_exists', message: 'Ya existe esa cuenta.'}));
+ assert.equal(await page.locator('button[type=submit]').isEnabled(), true);
+ assert.equal(await page.locator('#user-create-result').textContent(), 'Ya existe esa cuenta.');
+ await page.locator('button[type=submit]').click();
+ await page.evaluate(() => { window.requests[1].deferred.reject({}, 'error', 'fallo'); });
+ assert.equal(await page.locator('button[type=submit]').isEnabled(), true);
+ await page.locator('button[type=submit]').click();
+ await page.evaluate(() => window.requests[2].deferred.resolve({status: 'success', code: 'user_created_mail_failed', message: 'La cuenta se creó, pero no se pudo enviar el correo.'}));
+ assert.equal(await page.locator('#user-create-result').evaluate(el => el.classList.contains('alert-warning')), true);
+ assert.equal(await page.locator('button[type=submit]').isDisabled(), true);
+ for (const width of [375, 1280]) {
+ await page.setViewportSize({width, height: 800});
+ for (const theme of ['light', 'dark']) {
+ await page.evaluate(value => document.documentElement.dataset.bsTheme = value, theme);
+ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true);
+ assert.equal(await page.locator('h1').textContent(), 'Añadir usuario');
+ }
+ }
+ } finally {
+ await browser.close();
+ }
+});