From 3ff153e59d1a893309dc81d934ce35b9b0c7825c Mon Sep 17 00:00:00 2001 From: Juank de Gorvet Date: Sun, 25 Oct 2026 04:22:33 -0400 Subject: [PATCH 1/2] =?UTF-8?q?Permite=20crear=20usuarios=20desde=20admini?= =?UTF-8?q?straci=C3=B3n=20con=20invitaci=C3=B3n=20por=20correo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 + docs/user-admin.md | 17 +- maintenance/mejoras-pendientes.md | 4 +- .../controllers/auth-ui/AuthController.php | 5 +- .../user-admin/UserAdminController.php | 23 +++ .../user-admin/user-adminCreate.meta.php | 6 + .../app/views/user-admin/user-adminCreate.php | 33 ++++ .../app/views/user-admin/user-adminIndex.php | 5 + .../application/routes/routes_admin_users.php | 6 + .../routes/routes_ajax_admin_users.php | 5 + .../user-admin/javascript/user-create.js | 33 ++++ resources/modules/user-admin/module.php | 2 +- .../references/authentication-contracts.md | 2 + src/GFrame/Auth/AuthModel.php | 21 +- .../Contracts/UserInvitationRepository.php | 10 + src/GFrame/Auth/UserAdministrationService.php | 62 +++++- src/GFrame/Auth/UserModel.php | 22 ++- tests/UserInvitationTest.php | 179 ++++++++++++++++++ tests/js/user-create.browser.test.cjs | 66 +++++++ 19 files changed, 491 insertions(+), 14 deletions(-) create mode 100644 resources/modules/user-admin/application/app/views/user-admin/user-adminCreate.meta.php create mode 100644 resources/modules/user-admin/application/app/views/user-admin/user-adminCreate.php create mode 100644 resources/modules/user-admin/javascript/user-create.js create mode 100644 src/GFrame/Auth/Contracts/UserInvitationRepository.php create mode 100644 tests/UserInvitationTest.php create mode 100644 tests/js/user-create.browser.test.cjs 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..017745c 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» 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'); +?> +
+

Añadir usuario

+
+
+ +
+
+ + +
Escribe un correo electrónico válido.
+
+
+ + +
Selecciona un rol.
+
+

El usuario recibirá un enlace para definir su contraseña y verificar la cuenta. El enlace caduca en 24 horas.

+
+ +
+ Cancelar + +
+
+
+
diff --git a/resources/modules/user-admin/application/app/views/user-admin/user-adminIndex.php b/resources/modules/user-admin/application/app/views/user-admin/user-adminIndex.php index aafe723..079bc1b 100644 --- a/resources/modules/user-admin/application/app/views/user-admin/user-adminIndex.php +++ b/resources/modules/user-admin/application/app/views/user-admin/user-adminIndex.php @@ -11,7 +11,12 @@
+

Usuarios

+ + Añadir usuario + +
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/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(); + } +}); From df3fc0314534b6620e0a97bd801de678f5de58c5 Mon Sep 17 00:00:00 2001 From: Juank de Gorvet Date: Sun, 25 Oct 2026 04:29:50 -0400 Subject: [PATCH 2/2] Precisa permisos de alta y verifica rutas y dependencia de acceso --- docs/user-admin.md | 4 +++- tests/UserAdminUiTest.php | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/user-admin.md b/docs/user-admin.md index 017745c..9ab4f1f 100644 --- a/docs/user-admin.md +++ b/docs/user-admin.md @@ -140,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/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('', $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); }