feat: control de acceso y visibilidad de funciones de plugins por rol (v1.24.0)

main
Juan Manuel Bardallo 2026-10-28 20:12:50 +01:00
parent 5d206432a5
commit b6007ecf8a
7 changed files with 483 additions and 32 deletions

View File

@ -1,5 +1,16 @@
# Changelog
## [1.24.0] - 2026-10-28
### Added
- **Control de Acceso y Visibilidad de Funciones de Plugins por Rol (`PluginService`, `PluginSettingsComponent`, `ExecuteCommandComponent`)**:
- Incorporada especificación declarativa de roles autorizados (`roles?: string[]`) en la interfaz `PluginCommand`.
- Creada matriz interactiva de permisos en `PluginSettingsComponent` dentro de la nueva subpestaña *"Funciones y Roles"*, permitiendo a los administradores activar o revocar gráficamente el acceso a cada comando expuesto por cualquier plugin para cada rol del sistema.
- Los roles disponibles se extraen dinámicamente desde `ogcore` (`GET /user-groups`), con respaldo seguro en los roles canónicos de OpenGnSys (`ROLE_SUPER_ADMIN`, `ROLE_ORGANIZATIONAL_UNIT_ADMIN`, `ROLE_ORGANIZATIONAL_UNIT_OPERATOR`, `ROLE_ORGANIZATIONAL_UNIT_MINIMAL`).
- La personalización se persiste en la base de datos dentro de `configData.command_roles` de forma 100% desacoplada sin requerir alteraciones en el esquema backend.
- En `PluginService.getPluginGroups()`, los comandos disponibles en los menús de acción (`ExecuteCommandComponent`) se filtran reactivamente en función del rol y permisos del usuario autenticado, ocultando totalmente aquellas opciones que no le correspondan (ej. un conserje con perfil básico solo visualiza Reservar, Liberar y Renovar puesto, permaneciendo invisibles las funciones de instalación o mantenimiento).
- Total adhesión a la sintaxis moderna de control de flujo Angular (`@if`, `@for`) y diseño modular de estilos CSS sin atributos inline.
## [1.23.0] - 2026-10-27
### Added

View File

@ -1,6 +1,6 @@
{
"name": "og-webconsole",
"version": "1.23.0",
"version": "1.24.0",
"scripts": {
"ng": "ng",
"start": "ng serve",

View File

@ -913,3 +913,138 @@
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* =========================================================================
MATRIZ DE CONTROL DE ACCESO A FUNCIONES POR ROL
========================================================================= */
.roles-intro-text {
margin: 0 0 16px 0;
font-size: 0.88rem;
color: #64748b;
line-height: 1.5;
}
.roles-matrix-card {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
}
.roles-table-wrapper {
overflow-x: auto;
width: 100%;
}
.roles-matrix-table {
width: 100%;
border-collapse: collapse;
text-align: left;
}
.roles-matrix-table thead tr {
background: #f8fafc;
border-bottom: 2px solid #e2e8f0;
}
.col-command-header {
padding: 14px 18px;
font-size: 0.82rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #475569;
min-width: 260px;
}
.col-role-header {
padding: 12px 16px;
text-align: center;
min-width: 140px;
border-left: 1px solid #f1f5f9;
}
.role-header-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.role-header-name {
font-size: 0.85rem;
font-weight: 700;
color: #1e293b;
}
.role-header-key {
font-size: 0.70rem;
font-weight: 600;
color: #94a3b8;
font-family: monospace;
}
.command-role-row {
border-bottom: 1px solid #f1f5f9;
transition: background-color 0.15s ease;
}
.command-role-row:hover {
background-color: #f8fafc;
}
.command-identity-cell {
padding: 14px 18px;
vertical-align: middle;
}
.command-badge-wrapper {
display: flex;
align-items: center;
gap: 12px;
}
.command-icon-box {
width: 36px;
height: 36px;
border-radius: 8px;
background: #f1f5f9;
color: #0284c7;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.command-icon-box mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
.command-details-box {
display: flex;
flex-direction: column;
gap: 2px;
}
.command-title-text {
font-size: 0.90rem;
font-weight: 600;
color: #0f172a;
}
.command-slug-subtext {
font-size: 0.74rem;
color: #64748b;
font-family: monospace;
}
.role-checkbox-cell {
padding: 12px 16px;
text-align: center;
vertical-align: middle;
border-left: 1px solid #f1f5f9;
}

View File

@ -24,32 +24,46 @@
</div>
@if (plugin.enabled) {
@if (plugin.recordsEndpoint) {
@if (plugin.recordsEndpoint || (plugin.commands && plugin.commands.length > 0)) {
<div class="subtabs-navigation-bar">
<button
type="button"
class="subtab-nav-item"
[class.active]="activeSubTab === 0"
(click)="setSubTab(0)">
[class.active]="activeSubTab === 'settings'"
(click)="setSubTab('settings')">
<mat-icon class="subtab-nav-icon">tune</mat-icon>
<span>Configuración</span>
</button>
<button
type="button"
class="subtab-nav-item"
[class.active]="activeSubTab === 1"
(click)="setSubTab(1)">
<mat-icon class="subtab-nav-icon">history</mat-icon>
<span>Historial y Registros</span>
@if (recordsTotal > 0) {
<span class="subtab-counter-badge">{{ recordsTotal }}</span>
}
</button>
@if (plugin.commands && plugin.commands.length > 0) {
<button
type="button"
class="subtab-nav-item"
[class.active]="activeSubTab === 'permissions'"
(click)="setSubTab('permissions')">
<mat-icon class="subtab-nav-icon">admin_panel_settings</mat-icon>
<span>Funciones y Roles</span>
<span class="subtab-counter-badge">{{ plugin.commands.length }}</span>
</button>
}
@if (plugin.recordsEndpoint) {
<button
type="button"
class="subtab-nav-item"
[class.active]="activeSubTab === 'records'"
(click)="setSubTab('records')">
<mat-icon class="subtab-nav-icon">history</mat-icon>
<span>Historial y Registros</span>
@if (recordsTotal > 0) {
<span class="subtab-counter-badge">{{ recordsTotal }}</span>
}
</button>
}
</div>
}
@if (activeSubTab === 0) {
@if (activeSubTab === 'settings') {
<div class="settings-form-wrapper">
<div class="section-header-row">
<div class="section-title-box">
@ -172,7 +186,80 @@
}
</div>
</div>
} @else if (activeSubTab === 1 && plugin.recordsEndpoint) {
} @else if (activeSubTab === 'permissions' && plugin.commands && plugin.commands.length > 0) {
<div class="settings-form-wrapper">
<div class="section-header-row">
<div class="section-title-box">
<mat-icon class="section-icon">admin_panel_settings</mat-icon>
<h3 class="section-heading">Control de Acceso a Funciones por Rol</h3>
</div>
<div class="form-actions-bar top-actions">
<button mat-stroked-button color="warn" (click)="resetRoles()" class="btn-reset">
<mat-icon>restart_alt</mat-icon>
Restaurar por defecto
</button>
<button mat-raised-button color="primary" (click)="saveRoles()" class="btn-save" [disabled]="!hasRoleChanges">
<mat-icon>save</mat-icon>
Guardar Permisos
</button>
</div>
</div>
<p class="roles-intro-text">
Configura qué roles de usuario tienen autorización para visualizar y ejecutar cada una de las funciones expuestas por este plugin. Los usuarios que no pertenezcan a los roles seleccionados no verán la opción disponible en el menú de acciones de la consola.
</p>
<div class="roles-matrix-card">
<div class="roles-table-wrapper">
<table class="roles-matrix-table">
<thead>
<tr>
<th class="col-command-header">Función / Acción del Plugin</th>
@for (role of availableRoles; track role.key) {
<th class="col-role-header">
<div class="role-header-content">
<span class="role-header-name">{{ role.name }}</span>
<span class="role-header-key">{{ role.shortCode || role.key }}</span>
</div>
</th>
}
</tr>
</thead>
<tbody>
@for (cmd of plugin.commands; track cmd.slug) {
<tr class="command-role-row">
<td class="command-identity-cell">
<div class="command-badge-wrapper">
<div class="command-icon-box">
<mat-icon>{{ cmd.icon || 'play_circle' }}</mat-icon>
</div>
<div class="command-details-box">
<span class="command-title-text">{{ getCommandLabel(cmd) }}</span>
<span class="command-slug-subtext">{{ cmd.slug }}</span>
</div>
</div>
</td>
@for (role of availableRoles; track role.key) {
<td class="role-checkbox-cell">
<mat-checkbox
[checked]="isRoleChecked(cmd.slug, role.key)"
[disabled]="role.key === 'ROLE_SUPER_ADMIN'"
(change)="toggleRole(cmd.slug, role.key, $event.checked)"
color="primary"
[title]="role.key === 'ROLE_SUPER_ADMIN' ? 'Super Admin siempre tiene acceso total' : ('Permitir a ' + role.name)">
</mat-checkbox>
</td>
}
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
} @else if (activeSubTab === 'records' && plugin.recordsEndpoint) {
<div class="records-wrapper">
<div class="records-header-row">
<div class="records-title-group">

View File

@ -1,9 +1,10 @@
import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChanges, ChangeDetectorRef, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { PluginManifest, PluginService, SchemaField } from '@services/plugin.service';
import { PluginManifest, PluginCommand, PluginService, SchemaField, RoleDefinition } from '@services/plugin.service';
import { ConfigService } from '@services/config.service';
import { ToastrService } from 'ngx-toastr';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core';
import { Subscription } from 'rxjs';
@Component({
@ -18,7 +19,10 @@ export class PluginSettingsComponent implements OnInit, OnChanges, OnDestroy {
schema: SchemaField[] = [];
configData: Record<string, any> = {};
editingScripts: Record<string, boolean> = {};
activeSubTab: number = 0; // 0: Configuración, 1: Historial y Registros
activeSubTab: string = 'settings'; // 'settings' | 'permissions' | 'records'
availableRoles: RoleDefinition[] = [];
commandRoles: Record<string, string[]> = {};
hasRoleChanges: boolean = false;
records: any[] = [];
recordsTotal: number = 0;
recordsPage: number = 1;
@ -32,6 +36,7 @@ export class PluginSettingsComponent implements OnInit, OnChanges, OnDestroy {
private sanitizer = inject(DomSanitizer);
private http = inject(HttpClient);
private configService = inject(ConfigService);
private translate = inject(TranslateService);
constructor(
private pluginService: PluginService,
@ -55,7 +60,8 @@ export class PluginSettingsComponent implements OnInit, OnChanges, OnDestroy {
this.recordsTotal = 0;
this.recordsPage = 1;
this.recordsSearch = '';
this.activeSubTab = 0;
this.activeSubTab = 'settings';
this.hasRoleChanges = false;
this.loadPluginData();
}
}
@ -66,13 +72,85 @@ export class PluginSettingsComponent implements OnInit, OnChanges, OnDestroy {
if (this.plugin) {
this.schema = this.plugin.schema;
this.configData = this.pluginService.getPluginConfig(this.pluginName);
if (this.activeSubTab === 1 && this.plugin.recordsEndpoint) {
// Cargar catálogo de roles y matriz de permisos de comandos
this.pluginService.getUserRoles().subscribe(roles => {
this.availableRoles = roles;
this.initCommandRoles();
this.cdr.markForCheck();
});
if (this.activeSubTab === 'records' && this.plugin.recordsEndpoint) {
this.loadRecords(this.recordsPage);
}
}
this.cdr.markForCheck();
}
private initCommandRoles(): void {
if (!this.plugin || !this.plugin.commands) return;
const map: Record<string, string[]> = {};
for (const cmd of this.plugin.commands) {
map[cmd.slug] = this.pluginService.getCommandRoles(this.pluginName, cmd.slug);
}
this.commandRoles = map;
this.hasRoleChanges = false;
}
getCommandLabel(cmd: PluginCommand): string {
const key = cmd.translationKey || cmd.labelKey;
if (key) {
const trans = this.translate.instant(key);
if (trans && trans !== key) {
return trans;
}
}
return cmd.label || cmd.slug;
}
isRoleChecked(commandSlug: string, roleKey: string): boolean {
const roles = this.commandRoles[commandSlug] || [];
return roles.includes(roleKey);
}
toggleRole(commandSlug: string, roleKey: string, checked: boolean): void {
const current = [...(this.commandRoles[commandSlug] || [])];
if (checked) {
if (!current.includes(roleKey)) {
current.push(roleKey);
}
} else {
const idx = current.indexOf(roleKey);
if (idx !== -1) {
current.splice(idx, 1);
}
}
this.commandRoles[commandSlug] = current;
this.hasRoleChanges = true;
this.cdr.markForCheck();
}
saveRoles(): void {
if (!this.pluginName) return;
this.pluginService.savePluginCommandRoles(this.pluginName, this.commandRoles);
this.hasRoleChanges = false;
this.toast.success('Permisos de funciones por rol actualizados correctamente', this.plugin?.title || 'Plugin');
}
resetRoles(): void {
if (!this.plugin || !this.plugin.commands) return;
const map: Record<string, string[]> = {};
for (const cmd of this.plugin.commands) {
map[cmd.slug] = cmd.roles && Array.isArray(cmd.roles)
? [...cmd.roles]
: this.availableRoles.map(r => r.key);
}
this.commandRoles = map;
this.hasRoleChanges = true;
this.toast.info('Permisos restaurados a valores por defecto del plugin', this.plugin?.title || 'Plugin');
this.cdr.markForCheck();
}
toggleEnabled(checked: boolean): void {
if (!this.pluginName) return;
if (this.plugin) {
@ -201,9 +279,9 @@ export class PluginSettingsComponent implements OnInit, OnChanges, OnDestroy {
this.toast.info('Configuración restaurada a valores por defecto', this.plugin?.title || 'Plugin');
}
setSubTab(tab: number): void {
setSubTab(tab: string): void {
this.activeSubTab = tab;
if (tab === 1 && this.records.length === 0 && this.plugin?.recordsEndpoint) {
if (tab === 'records' && this.records.length === 0 && this.plugin?.recordsEndpoint) {
this.loadRecords(1);
}
this.cdr.markForCheck();

View File

@ -48,7 +48,7 @@ export class AuthService {
}
/** Roles básicos que aparecen en el token */
private get roles(): string[] {
public get roles(): string[] {
return this.tokenPayload?.roles || [];
}

View File

@ -4,6 +4,7 @@ import { BehaviorSubject, Observable, forkJoin, of } from 'rxjs';
import { catchError, map, switchMap, tap } from 'rxjs/operators';
import { TranslateService } from '@ngx-translate/core';
import { ConfigService } from './config.service';
import { AuthService } from './auth.service';
export interface CommandParam {
key: string;
@ -27,6 +28,13 @@ export interface PluginCommand {
params?: CommandParam[];
confirm?: string;
confirmKey?: string;
roles?: string[]; // Roles autorizados por defecto (ej. ['ROLE_SUPER_ADMIN', 'ROLE_ORGANIZATIONAL_UNIT_ADMIN'])
}
export interface RoleDefinition {
key: string; // Clave del rol Symfony (ej: 'ROLE_SUPER_ADMIN')
name: string; // Nombre descriptivo (ej: 'Super Admin')
shortCode?: string;// Código corto usado en frontend (ej: 'super-admin')
}
export interface PluginBadgeRule {
@ -105,6 +113,7 @@ export class PluginService {
private http = inject(HttpClient);
private configService = inject(ConfigService);
private translateService = inject(TranslateService);
private authService = inject(AuthService);
private allPlugins: PluginManifest[] = [];
private activePlugins: PluginManifest[] = [];
@ -444,12 +453,9 @@ export class PluginService {
.filter(p => p.commands && p.commands.length > 0)
.map(p => {
const translatedTitle = p.titleKey ? this.translateService.instant(p.titleKey) : p.title;
return {
plugin: {
...p,
title: (translatedTitle && translatedTitle !== p.titleKey) ? translatedTitle : p.title
},
commands: p.commands.map(cmd => {
const filteredCommands = p.commands
.filter(cmd => this.isCommandAllowedForUser(cmd, p.name))
.map(cmd => {
const key = cmd.translationKey || cmd.labelKey;
if (key) {
const trans = this.translateService.instant(key);
@ -458,9 +464,143 @@ export class PluginService {
}
}
return cmd;
})
});
return {
plugin: {
...p,
title: (translatedTitle && translatedTitle !== p.titleKey) ? translatedTitle : p.title
},
commands: filteredCommands
};
});
})
.filter(group => group.commands.length > 0);
}
/**
* Comprueba si el usuario autenticado tiene permiso para ejecutar un comando específico.
* Jerarquía: configData.command_roles[slug] > command.roles > público (todos los roles)
*/
isCommandAllowedForUser(command: PluginCommand, pluginName: string): boolean {
if (this.authService.isSuperAdmin) {
return true;
}
const config = this.getPluginConfig(pluginName);
const commandRolesConfig = config?.['command_roles'] || {};
let allowedRoles: string[] | undefined = commandRolesConfig[command.slug];
if (!allowedRoles || !Array.isArray(allowedRoles)) {
allowedRoles = command.roles;
}
// Si ni en configuración ni en el manifiesto se especifican roles, la función es pública
if (!allowedRoles || allowedRoles.length === 0) {
return true;
}
const userRoles = (this.authService.roles || []).map(r => r.toUpperCase());
const currentCategory = (this.authService.getCurrentRole() || '').toLowerCase();
return allowedRoles.some(role => {
const rUpper = role.toUpperCase();
const rLower = role.toLowerCase();
return userRoles.includes(rUpper) ||
rLower === currentCategory ||
(rUpper === 'ROLE_SUPER_ADMIN' && this.authService.isSuperAdmin) ||
(rUpper === 'ROLE_ORGANIZATIONAL_UNIT_ADMIN' && this.authService.isOUAdmin) ||
(rUpper === 'ROLE_ORGANIZATIONAL_UNIT_OPERATOR' && this.authService.isOUOperator) ||
(rUpper === 'ROLE_ORGANIZATIONAL_UNIT_MINIMAL' && this.authService.isOUMinimal);
});
}
/**
* Obtiene la lista de roles/grupos disponibles en el sistema consultando a ogCore,
* con fallback seguro a los roles canónicos de OpenGnSys.
*/
getUserRoles(): Observable<RoleDefinition[]> {
const apiUrl = this.configService.apiUrl;
const defaultRoles: RoleDefinition[] = [
{ key: 'ROLE_SUPER_ADMIN', name: 'Super Admin', shortCode: 'super-admin' },
{ key: 'ROLE_ORGANIZATIONAL_UNIT_ADMIN', name: 'Admin de aulas', shortCode: 'ou-admin' },
{ key: 'ROLE_ORGANIZATIONAL_UNIT_OPERATOR', name: 'Operador de aulas', shortCode: 'ou-operator' },
{ key: 'ROLE_ORGANIZATIONAL_UNIT_MINIMAL', name: 'Usuario básico de aulas', shortCode: 'ou-minimal' },
];
if (!apiUrl) {
return of(defaultRoles);
}
return this.http.get<any>(`${apiUrl}/user-groups?page=1&itemsPerPage=1000`).pipe(
map(res => {
const groups = res?.['hydra:member'] || (Array.isArray(res) ? res : []);
if (!Array.isArray(groups) || groups.length === 0) {
return defaultRoles;
}
const roles: RoleDefinition[] = [];
const seenKeys = new Set<string>();
// Descubrir roles desde los grupos registrados en la base de datos
for (const g of groups) {
const perms = Array.isArray(g.permissions) ? g.permissions : [];
for (const perm of perms) {
if (!seenKeys.has(perm)) {
seenKeys.add(perm);
const matchedDef = defaultRoles.find(dr => dr.key === perm);
roles.push({
key: perm,
name: g.name || matchedDef?.name || perm,
shortCode: matchedDef?.shortCode
});
}
}
}
// Garantizar que todos los roles canónicos estándar estén presentes
for (const dr of defaultRoles) {
if (!seenKeys.has(dr.key)) {
roles.push(dr);
}
}
return roles;
}),
catchError(() => of(defaultRoles))
);
}
/**
* Devuelve los roles actualmente asignados a un comando del plugin.
*/
getCommandRoles(pluginName: string, commandSlug: string): string[] {
const plugin = this.getPlugin(pluginName);
const cmd = plugin?.commands?.find(c => c.slug === commandSlug);
const config = this.getPluginConfig(pluginName);
const commandRolesConfig = config?.['command_roles'] || {};
if (commandRolesConfig[commandSlug] && Array.isArray(commandRolesConfig[commandSlug])) {
return [...commandRolesConfig[commandSlug]];
}
if (cmd?.roles && Array.isArray(cmd.roles)) {
return [...cmd.roles];
}
return ['ROLE_SUPER_ADMIN', 'ROLE_ORGANIZATIONAL_UNIT_ADMIN', 'ROLE_ORGANIZATIONAL_UNIT_OPERATOR', 'ROLE_ORGANIZATIONAL_UNIT_MINIMAL'];
}
/**
* Persiste la matriz de roles configurados para los comandos de un plugin.
*/
savePluginCommandRoles(pluginName: string, commandRoles: Record<string, string[]>): void {
const currentConfig = this.getPluginConfig(pluginName);
const updatedConfig = {
...currentConfig,
command_roles: commandRoles
};
this.savePluginConfig(pluginName, updatedConfig);
}
getCommands(): PluginCommand[] {