diff --git a/CHANGELOG.md b/CHANGELOG.md index 268cb2e..23e841f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/ogWebconsole/package.json b/ogWebconsole/package.json index 52f528b..a81be82 100644 --- a/ogWebconsole/package.json +++ b/ogWebconsole/package.json @@ -1,6 +1,6 @@ { "name": "og-webconsole", - "version": "1.23.0", + "version": "1.24.0", "scripts": { "ng": "ng", "start": "ng serve", diff --git a/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.css b/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.css index 5f429af..65b49ff 100644 --- a/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.css +++ b/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.css @@ -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; +} diff --git a/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.html b/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.html index aa31f86..aa87164 100644 --- a/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.html +++ b/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.html @@ -24,32 +24,46 @@ @if (plugin.enabled) { - @if (plugin.recordsEndpoint) { + @if (plugin.recordsEndpoint || (plugin.commands && plugin.commands.length > 0)) {
- + @if (plugin.commands && plugin.commands.length > 0) { + + } + + @if (plugin.recordsEndpoint) { + + }
} - @if (activeSubTab === 0) { + @if (activeSubTab === 'settings') {
@@ -172,7 +186,80 @@ }
- } @else if (activeSubTab === 1 && plugin.recordsEndpoint) { + } @else if (activeSubTab === 'permissions' && plugin.commands && plugin.commands.length > 0) { +
+
+
+ admin_panel_settings +

Control de Acceso a Funciones por Rol

+
+ +
+ + +
+
+ +

+ 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. +

+ +
+
+ + + + + @for (role of availableRoles; track role.key) { + + } + + + + @for (cmd of plugin.commands; track cmd.slug) { + + + + @for (role of availableRoles; track role.key) { + + } + + } + +
Función / Acción del Plugin +
+ {{ role.name }} + {{ role.shortCode || role.key }} +
+
+
+
+ {{ cmd.icon || 'play_circle' }} +
+
+ {{ getCommandLabel(cmd) }} + {{ cmd.slug }} +
+
+
+ + +
+
+
+
+ } @else if (activeSubTab === 'records' && plugin.recordsEndpoint) {
diff --git a/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.ts b/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.ts index b93d5ac..b157bee 100644 --- a/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.ts +++ b/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.ts @@ -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 = {}; editingScripts: Record = {}; - activeSubTab: number = 0; // 0: Configuración, 1: Historial y Registros + activeSubTab: string = 'settings'; // 'settings' | 'permissions' | 'records' + availableRoles: RoleDefinition[] = []; + commandRoles: Record = {}; + 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 = {}; + 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 = {}; + 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(); diff --git a/ogWebconsole/src/app/services/auth.service.ts b/ogWebconsole/src/app/services/auth.service.ts index d3b0104..a7cd867 100644 --- a/ogWebconsole/src/app/services/auth.service.ts +++ b/ogWebconsole/src/app/services/auth.service.ts @@ -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 || []; } diff --git a/ogWebconsole/src/app/services/plugin.service.ts b/ogWebconsole/src/app/services/plugin.service.ts index ee54724..72dec95 100644 --- a/ogWebconsole/src/app/services/plugin.service.ts +++ b/ogWebconsole/src/app/services/plugin.service.ts @@ -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 { + 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(`${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(); + + // 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): void { + const currentConfig = this.getPluginConfig(pluginName); + const updatedConfig = { + ...currentConfig, + command_roles: commandRoles + }; + this.savePluginConfig(pluginName, updatedConfig); } getCommands(): PluginCommand[] {