Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
21 changes: 18 additions & 3 deletions docs/user-admin.md
Original file line number Diff line number Diff line change
Expand Up @@ -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],
],
];
```
Expand All @@ -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.
Expand All @@ -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»

Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions maintenance/mejoras-pendientes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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']),
],
]);
}
Expand All @@ -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();
Expand Down Expand Up @@ -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.',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?php

return [
'metaTags' => ['title' => 'Añadir usuario - ' . site_name],
'js' => ['public/js/modules/user-admin/user-create.js'],
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php
$roles = (array)($data['data']['roles'] ?? []);
$escape = static fn($value): string => htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
?>
<div class="col-12">
<div class="pagetitle"><h1>Añadir usuario</h1></div>
<div class="card"><div class="card-body">
<form id="user-create-form" class="needs-validation" data-save-url="<?= $escape(site_url . 'ajax/admin/users/create') ?>" novalidate>
<?= \GFrame\Security\CsrfToken::fields() ?>
<div class="row g-3">
<div class="col-12 col-md-6">
<label for="user-create-email" class="form-label">Correo electrónico</label>
<input id="user-create-email" name="email" class="form-control" type="email" autocomplete="off" maxlength="190" required>
<div class="invalid-feedback">Escribe un correo electrónico válido.</div>
</div>
<div class="col-12 col-md-6">
<label for="user-create-role" class="form-label">Rol</label>
<select id="user-create-role" name="role_id" class="form-select" required>
<option value="">Selecciona un rol</option>
<?php foreach ($roles as $role): ?><option value="<?= (int)$role['role_id'] ?>"><?= $escape($role['name']) ?></option><?php endforeach; ?>
</select>
<div class="invalid-feedback">Selecciona un rol.</div>
</div>
<div class="col-12"><p class="text-body-secondary mb-0">El usuario recibirá un enlace para definir su contraseña y verificar la cuenta. El enlace caduca en 24 horas.</p></div>
</div>
<div id="user-create-result" class="alert mt-4" role="status" hidden></div>
<div class="d-flex justify-content-end gap-2 mt-4">
<a class="btn btn-outline-secondary" href="<?= $escape(site_url . 'admin/users') ?>">Cancelar</a>
<button class="btn btn-primary" type="submit">Crear usuario y enviar enlace</button>
</div>
</form>
</div></div>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@
<input type="hidden" name="csrfTimestamp" value="<?= htmlspecialchars((string)($_SESSION['csrfTimestamp'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
</form>
<div class="pagetitle">
<div class="d-flex align-items-center flex-wrap">
<h1>Usuarios</h1>
<?php if (!empty($moduleData['can_create'])): ?>
<a class="btn btn-primary ms-0 ms-md-3 mt-3 mt-md-0" href="<?= htmlspecialchars(site_url . 'admin/users/create', ENT_QUOTES, 'UTF-8') ?>">Añadir usuario</a>
<?php endif; ?>
</div>
</div>
<div class="card">
<div class="card-body">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand Down
33 changes: 33 additions & 0 deletions resources/modules/user-admin/javascript/user-create.js
Original file line number Diff line number Diff line change
@@ -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);
2 changes: 1 addition & 1 deletion resources/modules/user-admin/module.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading