From b3b6500f9b5ca1398ac2eb50c84c26a5921e1691 Mon Sep 17 00:00:00 2001 From: Tim Jacomb Date: Fri, 3 Apr 2026 22:19:00 +0100 Subject: [PATCH 01/88] Init --- pom.xml | 6 +- .../RoleStrategyConfig/assign-roles.jelly | 246 +-- .../RoleStrategyConfig/index.jelly | 460 ++--- .../RoleStrategyConfig/list-macros.jelly | 73 +- .../manage-agent-roles.jelly | 98 +- .../manage-global-roles.jelly | 97 +- .../manage-project-roles.jelly | 139 +- .../permission-templates.jelly | 75 +- .../RoleStrategyConfig/sidepanel.jelly | 36 +- src/main/webapp/css/role-strategy.css | 860 +++++++-- src/main/webapp/js/table.js | 244 ++- src/main/webapp/js/tableManage.js | 1542 +++++++++++++---- src/main/webapp/js/tableTemplates.js | 25 +- 13 files changed, 2435 insertions(+), 1466 deletions(-) diff --git a/pom.xml b/pom.xml index 7a42ecc7..f8adf88f 100644 --- a/pom.xml +++ b/pom.xml @@ -32,8 +32,8 @@ 999999-SNAPSHOT - 2.479 - ${jenkins.baseline}.3 + 2.541 + 2.557-rc38182.209d0e525304 jenkinsci/${project.artifactId}-plugin 13.0.0 640 @@ -45,7 +45,7 @@ io.jenkins.tools.bom bom-${jenkins.baseline}.x - 5054.v620b_5d2b_d5e6 + 6210.v69ea_fd8a_f010 pom import diff --git a/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/assign-roles.jelly b/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/assign-roles.jelly index 31cb3b83..decba20d 100644 --- a/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/assign-roles.jelly +++ b/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/assign-roles.jelly @@ -1,241 +1,5 @@ - - - - - - - - - diff --git a/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/sidepanel.jelly b/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/sidepanel.jelly index f94c0388..6cf69813 100644 --- a/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/sidepanel.jelly +++ b/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/sidepanel.jelly @@ -1,34 +1,4 @@ - - - - - - - - - - - \ No newline at end of file + + + diff --git a/src/main/webapp/css/role-strategy.css b/src/main/webapp/css/role-strategy.css index 20d64607..e1262047 100644 --- a/src/main/webapp/css/role-strategy.css +++ b/src/main/webapp/css/role-strategy.css @@ -1,27 +1,731 @@ -/* - * The MIT License - * - * Copyright (c) 2010-2011, Manufacture Française des Pneumatiques Michelin, - * Thomas Maurel, Romain Seguy - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - */ +/* Role Strategy Plugin - Card-based UI */ + +/* Ensure content fits within the one-column layout */ +#main-panel { + max-width: 100%; +} + +/* ============================================ + Container + ============================================ */ + +.rsp-container { + margin-bottom: 1rem; +} + +/* ============================================ + App Bar (search + filter + actions) + ============================================ */ + +.rsp-app-bar { + display: grid; + grid-template-columns: 1fr auto auto; + gap: calc(var(--section-padding) / 2); + margin-bottom: calc(var(--section-padding) / 2); +} + +.rsp-search-wrapper { + position: relative; +} + +.rsp-search-wrapper .jenkins-search__input { + padding-right: 2.25rem; +} + +/* ============================================ + Permission Filter Dropdown + ============================================ */ + +.rsp-filter { + position: absolute; + right: 0.375rem; + top: 50%; + transform: translateY(-50%); + z-index: 1; +} + +.rsp-filter__button { + padding: 0.25rem !important; + min-width: auto !important; + color: var(--text-color-secondary); + transition: color var(--standard-transition); +} + +button.rsp-filter__button--active { + color: var(--accent-color) !important; +} + +.rsp-filter__dropdown { + position: absolute; + top: calc(100% + 0.25rem); + right: 0; + z-index: 9999; + min-width: 260px; + max-height: 420px; + display: flex; + flex-direction: column; + background: var(--background); + border: var(--jenkins-border); + border-radius: var(--form-input-border-radius); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.rsp-filter__dropdown[hidden] { + display: none; +} + +.rsp-filter__header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0.75rem; + border-bottom: var(--jenkins-border); + flex-shrink: 0; +} + +.rsp-filter__header-label { + font-weight: var(--font-bold-weight); + font-size: 0.875rem; +} + +.rsp-filter__reset-button { + font-size: 0.8125rem; + color: var(--accent-color); + background: none; + border: none; + cursor: pointer; + padding: 0; +} + +.rsp-filter__reset-button:hover { + text-decoration: underline; +} + +.rsp-filter__search { + padding: 0.375rem 0.5rem; + border-bottom: var(--jenkins-border); + flex-shrink: 0; + position: relative; + display: flex; + align-items: center; +} + +.rsp-filter__search-input { + flex: 1; + padding: 0.25rem 1.75rem 0.25rem 0.5rem !important; + font-size: 0.8125rem !important; +} + +.rsp-filter__search-clear { + position: absolute; + right: 0.75rem; + background: none; + border: none; + cursor: pointer; + color: var(--text-color-secondary); + font-size: 1rem; + line-height: 1; + padding: 0.125rem; + display: none; +} + +.rsp-filter__search-clear--visible { + display: block; +} + +.rsp-filter__list { + padding: 0.375rem 0; + overflow-y: auto; + flex: 1; +} + +.rsp-filter__group-title { + font-weight: var(--font-bold-weight); + font-size: 0.6875rem; + color: var(--text-color-secondary); + text-transform: uppercase; + letter-spacing: 0.03em; + padding: 0.5rem 0.75rem 0.25rem; +} + +.rsp-filter__item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.75rem; + cursor: pointer; + width: 100%; + border: none; + background: none; + color: inherit; + font: inherit; + font-size: 0.875rem; + text-align: left; + transition: background var(--standard-transition); +} + +.rsp-filter__item:hover { + background: var(--item-background--hover); +} + +.rsp-filter__item--active { + background: var(--item-background--active, rgba(0, 0, 0, 0.05)); +} + +.rsp-filter__item--filter-hidden { + display: none; +} + +.rsp-filter__item-indicator { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + border: 2px solid var(--text-color-secondary); + flex-shrink: 0; + transition: all 0.15s; +} + +.rsp-filter__item--active .rsp-filter__item-indicator { + background: var(--accent-color); + border-color: var(--accent-color); +} + +.rsp-filter__group-title--filter-hidden { + display: none; +} + +.rsp-filter__no-results { + padding: 1rem 0.75rem; + color: var(--text-color-secondary); + font-size: 0.875rem; + text-align: center; + font-style: italic; +} + +.rsp-filter__no-results[hidden] { + display: none; +} + +/* ============================================ + Cards + ============================================ */ + +.rsp-cards { + display: flex; + flex-direction: column; +} + +.rsp-card { + border: var(--jenkins-border); + border-radius: var(--form-input-border-radius); + background: var(--card-background); + overflow: hidden; +} + +.rsp-card:not(:first-of-type) { + border-top-left-radius: 0; + border-top-right-radius: 0; + border-top: none; +} + +.rsp-card:not(:last-of-type) { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.rsp-card--hidden { + display: none; +} + +/* ============================================ + Card Header + ============================================ */ + +.rsp-card__header { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0.5rem 0.5rem 0.75rem; + cursor: pointer; + user-select: none; + min-width: 0; + transition: background var(--standard-transition); +} + +.rsp-card__header:hover { + background: var(--item-background--hover); +} + +.rsp-card__header:active { + background: var(--item-background--active); +} + +.rsp-card__name { + font-weight: var(--font-bold-weight); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex-shrink: 1; + min-width: 0; + color: var(--text-color); +} + +.rsp-card__pattern { + font-size: 0.8125rem; + color: var(--text-color-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex-shrink: 1; + min-width: 0; + cursor: pointer; +} + +.rsp-card__pattern:hover { + color: var(--link-color); + text-decoration: var(--link-text-decoration--hover); +} + +.rsp-card__template-badge { + font-size: 0.75rem; + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + background: var(--item-background--hover); + color: var(--text-color-secondary); + white-space: nowrap; + flex-shrink: 0; +} + +.rsp-card__summary { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-color-secondary); + font-size: 0.875rem; +} + +.rsp-card__summary--empty { + font-style: italic; + opacity: 0.7; +} + +.rsp-card__actions { + display: flex; + align-items: center; + gap: 0.125rem; + flex-shrink: 0; +} + +.rsp-card__action { + padding: 0 !important; + min-height: 2rem; + width: 2rem; +} + +.rsp-card__toggle { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + transition: transform var(--standard-transition); +} + +.rsp-card[aria-expanded="true"] .rsp-card__toggle { + transform: rotate(180deg); +} + +/* ============================================ + Card Body (collapsible) + ============================================ */ + +.rsp-card__body { + border-top: var(--jenkins-border); +} + +.rsp-card__body--collapsed { + height: 0; + overflow: hidden; + padding: 0; + border-top: none; + visibility: hidden; +} + +/* ============================================ + Permissions Section + ============================================ */ + +.rsp-perm__group { + display: flex; + flex-direction: column; + border: none; + padding: 0; + margin: 0; +} + +.rsp-perm__group:first-of-type { + margin-top: 0.75rem; +} + +.rsp-perm__group-title { + color: var(--text-color-secondary); + font-weight: var(--font-bold-weight); + font-size: 0.8125rem; + margin: 0 0.75rem; + padding: 0.5rem 0.5rem 0 0.5rem; +} + +.rsp-perm__permissions { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin: 0.5rem 0.75rem 0.75rem 0.75rem; +} + +.rsp-perm__item { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.25rem 0.5rem; + min-width: 150px; + border-radius: 0.375rem; + cursor: pointer; + transition: background-color 0.15s; +} + +.rsp-perm__item input[type="checkbox"] { + position: absolute; + opacity: 0; + width: 0; + height: 0; + pointer-events: none; +} + +.rsp-perm__item:hover { + background: var(--item-background--hover); +} + +.rsp-perm__item:has(input:focus-visible) { + box-shadow: 0 0 0 0.2rem var(--text-color); +} + +.rsp-perm__item:has(input:checked) { + background: var(--accent-color, #0b6cbd); + color: var(--background, #fff); +} + +.rsp-perm__item:has(input:checked):hover { + filter: brightness(1.1); +} + +.rsp-perm__item:has(input:checked) .rsp-perm__item-name { + color: inherit; +} + +.rsp-perm__item:has(input:checked) .rsp-perm__item-info { + color: inherit; + opacity: 0.8; +} + +.rsp-perm__item-name { + font-size: 0.875rem; + color: var(--text-color); +} + +.rsp-perm__item-implied { + font-size: 0.75rem; + color: var(--text-color-secondary); + font-style: italic; +} + +.rsp-perm__item:has(input:checked) .rsp-perm__item-implied { + color: inherit; +} + +.rsp-perm__item-info { + color: var(--text-color-secondary); + cursor: help; + display: inline-flex; + align-items: center; +} + +.rsp-perm__item-info svg { + width: 14px; + height: 14px; +} + +.rsp-perm__item--implied { + background: var(--item-background--hover); +} + +.rsp-perm__item--implied input[type="checkbox"] { + pointer-events: none; +} + +/* ============================================ + Assignments Section + ============================================ */ + +.rsp-assign { + border-top: var(--jenkins-border); + padding: 0.75rem; +} + +.rsp-assign__title { + font-size: 0.8125rem; + font-weight: var(--font-bold-weight); + color: var(--text-color-secondary); + margin: 0 0 0.5rem 0; +} + +.rsp-assign__list { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + margin-bottom: 0.5rem; +} + +.rsp-assign__chip { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.25rem 0.5rem; + border-radius: 0.375rem; + background: var(--item-background--hover); + font-size: 0.8125rem; + white-space: nowrap; +} + +.rsp-assign__chip--either { + border-left: 3px solid var(--warning); +} + +.rsp-assign__chip-icon { + display: inline-flex; + align-items: center; + flex-shrink: 0; +} + +.rsp-assign__chip-icon svg { + width: 14px; + height: 14px; +} + +.rsp-assign__chip-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + border: none; + background: none; + cursor: pointer; + color: var(--text-color-secondary); + border-radius: 50%; + padding: 0; + font-size: 0.875rem; + line-height: 1; + transition: background var(--standard-transition); +} + +.rsp-assign__chip-remove:hover { + background: rgba(0, 0, 0, 0.1); + color: var(--error-color); +} + +.rsp-assign__chip-migrate { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + border: none; + background: none; + cursor: pointer; + color: var(--text-color-secondary); + border-radius: 50%; + padding: 0; + transition: background var(--standard-transition); +} + +.rsp-assign__chip-migrate:hover { + background: rgba(0, 0, 0, 0.1); + color: var(--text-color); +} + +.rsp-assign__chip-migrate svg { + width: 14px; + height: 14px; +} + +.rsp-assign__show-more { + font-size: 0.8125rem; + color: var(--accent-color); + background: none; + border: none; + cursor: pointer; + padding: 0.25rem 0.5rem; +} + +.rsp-assign__show-more:hover { + text-decoration: underline; +} + +.rsp-assign__actions { + display: flex; + gap: 0.5rem; +} + +.rsp-assign__loading { + color: var(--text-color-secondary); + font-size: 0.8125rem; + font-style: italic; + padding: 0.25rem 0; +} + +/* ============================================ + Empty State + ============================================ */ + +.rsp-empty-state { + min-height: 100px; +} + +.rsp-empty-state[hidden] { + display: none; +} + +/* ============================================ + Highlight Animation + ============================================ */ + +@keyframes rsp-highlight { + from { background: var(--focus-input-glow, #C4C080); } + to { background: transparent; } +} + +.rsp-highlight-entry { + animation: rsp-highlight 5s; +} + +/* ============================================ + Read-only Mode + ============================================ */ + +.rsp-card.rsp-card--read-only .rsp-card__header { + cursor: default; +} + +.rsp-card.rsp-card--read-only .rsp-card__actions .rsp-card__action { + display: none; +} + +.rsp-card.rsp-card--read-only .rsp-perm__item input[type="checkbox"] { + pointer-events: none; +} + +.rsp-card.rsp-card--read-only .rsp-assign__chip-remove, +.rsp-card.rsp-card--read-only .rsp-assign__chip-migrate, +.rsp-card.rsp-card--read-only .rsp-assign__add, +.rsp-card.rsp-card--read-only .rsp-assign__actions { + display: none; +} + +/* ============================================ + Dirty indicator + ============================================ */ + +/* ============================================ + Collapsible Sections + ============================================ */ + +.rsp-section-collapsible .jenkins-section__title { + cursor: pointer; + user-select: none; +} + +.rsp-section-collapsible .jenkins-section__title::after { + content: ""; + display: inline-block; + width: 0.5rem; + height: 0.5rem; + border-right: 2px solid var(--text-color-secondary); + border-bottom: 2px solid var(--text-color-secondary); + transform: rotate(45deg); + margin-left: 0.5rem; + transition: transform 0.2s; + vertical-align: middle; +} + +.rsp-section-collapsible.rsp-section--collapsed .jenkins-section__title::after { + transform: rotate(-45deg); +} + +.rsp-section-collapsible.rsp-section--collapsed .rsp-container, +.rsp-section-collapsible.rsp-section--collapsed > .jenkins-form-item { + display: none; +} + +/* ============================================ + Dirty indicator + ============================================ */ + +#rs-dirty-indicator { + display: none; +} + +/* ============================================ + Add Role Dialog + ============================================ */ + +.rsp-dialog-content { + display: flex; + flex-direction: column; +} + +.rsp-dialog-content .jenkins-form-label { + font-weight: var(--font-bold-weight); + font-size: 0.875rem; + display: block; + margin-bottom: 0.375rem; +} + +.rsp-dialog-content .rsp-perm { + max-height: 300px; + overflow-y: auto; + border: var(--jenkins-border); + border-radius: var(--form-input-border-radius); + padding: 0.25rem 0; +} + +.rsp-dialog-content .rsp-perm__group:first-of-type { + margin-top: 0.25rem; +} + +.rsp-dialog-content .rsp-perm__permissions { + margin: 0.25rem 0.5rem 0.5rem 0.5rem; +} + +.rsp-dialog-content .rsp-perm__group-title { + margin: 0 0.5rem; + padding: 0.375rem 0.375rem 0 0.375rem; +} + +/* ============================================ + Ambiguity warning + ============================================ */ + +.rsp-ambiguity-warning { + margin-bottom: 1rem; +} + +/* ============================================ + Legacy table styles (Permission Templates page) + ============================================ */ .role-strategy-table { border-spacing: 2px; @@ -32,7 +736,7 @@ } .role-strategy-table .rsp-table--header-th { - text-align: center!important; + text-align: center !important; } .role-strategy-table thead .rsp-table--header-th.first { @@ -52,24 +756,24 @@ } .rsp-table__header-column { - vertical-align: bottom!important; - text-align: left!important; + vertical-align: bottom !important; + text-align: left !important; } .rsp-table__footer-column { - vertical-align: top!important; - text-align: left!important; + vertical-align: top !important; + text-align: left !important; } -.role-strategy-table th, .role-strategy-table td -{ - padding: 0 0.25rem!important; +.role-strategy-table th, +.role-strategy-table td { + padding: 0 0.25rem !important; vertical-align: middle; text-align: center; } .role-strategy-table .caption-row TH { - padding: 0.25rem!important; + padding: 0.25rem !important; width: 1rem; text-align: center; } @@ -80,15 +784,13 @@ } .role-strategy-table .rsp-table__permission, -.role-strategy-table .rsp-table--header-th -{ +.role-strategy-table .rsp-table--header-th { background: var(--table-body-background); } .role-strategy-table .caption-row TH span, .role-strategy-table .rsp-table--vertical span, -.role-strategy-table .group-row TD span -{ +.role-strategy-table .group-row TD span { writing-mode: vertical-rl; padding: 5px 0; } @@ -105,34 +807,27 @@ white-space: nowrap; } -.role-strategy-table div.pattern-cell { - display: flex; - justify-content: left; - align-items: center; -} - -.role-strategy-table div.pattern-cell svg { - margin-right: 3px; +.rsp-remove { cursor: pointer; + height: 16px; + display: flex; } -.role-strategy-table div.pattern-cell span { - white-space: nowrap; -} - -label.attach-previous { - margin-left: 0; +.rsp-table__cell { + display: flex; + align-items: center; + gap: 3px; } .highlighted, .highlighted td { - background-color: #FFF9C9!important; - color: var(--black)!important; + background-color: #FFF9C9 !important; + color: var(--black) !important; } .jenkins-checkbox { - vertical-align: middle; - margin: 3px 0px; + vertical-align: middle; + margin: 3px 0px; } @keyframes highlightentry { @@ -141,65 +836,18 @@ label.attach-previous { } .highlight-entry { - -webkit-animation: highlightentry 5s; - -moz-animation: highlightentry 5s; animation: highlightentry 5s; } -.jenkins-alert { - margin-top: 10px; -} - -.rsp-entry-not-found { - text-decoration: line-through; - color: grey; -} - -.rsp-table__icon-alert { - color: orange; -} - -.rsp-table__cell { - display: flex; - align-items: center; - gap: 3px; -} - -.rsp-remove, .migrate { +.rsp-highlight-input { cursor: pointer; - height: 16px; - display: flex; -} - -button.migrate { - background: none; - border: none; - color: inherit; - padding: 0; } -.patternAnchor { - cursor: pointer; - color: var(--link-color); - font-weight: var(--link-font-weight); -} - -.patternAnchor:hover { - text-decoration: var(--link-text-decoration--hover); -} - -.row-filter, .user-filter, .role-filter { +.row-filter { display: none; max-width: 500px; } - -.rsp-navigation__entries { - display: flex; - gap: 1rem; - margin-top: -15px; +.rsp-table__icon { + cursor: pointer; } - -.rsp-navigation__entries .jenkins-select { - max-width: 5rem; -} \ No newline at end of file diff --git a/src/main/webapp/js/table.js b/src/main/webapp/js/table.js index 392efd61..f9d89494 100644 --- a/src/main/webapp/js/table.js +++ b/src/main/webapp/js/table.js @@ -1,143 +1,101 @@ -/* - * The MIT License - * - * Copyright (c) 2010, Manufacture Française des Pneumatiques Michelin, Thomas Maurel - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - */ - -function debounce(func, timeout = 300) { - let timer; - return (...args) => { - clearTimeout(timer); - timer = setTimeout(() => { func.apply(this, args); }, timeout); - }; -} - -function ignoreKeys(code) { - switch (code) { - case "KeyS": - case "ArrowDown": - case "KeyW": - case "ArrowUp": - case "KeyA": - case "ArrowLeft": - case "KeyD": - case "ArrowRight": - case "Enter": - case "Escape": - return true; - } - return false; -} - -function getPreviousSiblings(elem, filter) { - let sibs = []; - while (elem = elem.previousSibling) { - if (elem.nodeType === 3) continue; // text node - sibs.push(elem); - } - return sibs; -} - -class TableHighlighter { - - constructor(id, decalx) { - this.table = document.getElementById(id); - this.decalx = decalx; - let trs = this.table.querySelectorAll('tbody tr'); - for (let row of trs){ - this.scan(row); - } - }; - - scan(tr) { - let descendants = tr.querySelectorAll('.rsp-highlight-input'); - for (let td of descendants) { - td.addEventListener('mouseenter', this.highlight); - td.addEventListener('mouseleave', this.highlight); - } - let stopNodes = tr.querySelectorAll("div.rsp-remove"); - let lastStop = stopNodes[stopNodes.length - 1]; - if (lastStop != null) { - let td = lastStop.closest('td'); - td.addEventListener('mouseenter', this.highlightRowOnly); - td.addEventListener('mouseleave', this.highlightRowOnly); - } - }; - - highlightRowOnly = e => { - let enable = e.type === 'mouseenter'; - let tr = e.target.closest("TR"); - if (enable) { - tr.classList.add('highlighted'); - } else { - tr.classList.remove('highlighted'); - } - } - - highlight = e => { - let enable = e.type === 'mouseenter'; - if (e.target.tagName === 'TD') { - let td = e.target; - let tr = td.parentNode; - let trs = this.table.querySelectorAll('tr.highlight-row'); - let position = getPreviousSiblings(td).length; - - let p = 0; - for (let row of trs) { - let num = position; - if (p==0) num = num - this.decalx; - p++; - let element = row.childNodes[num]; - if (enable) { - element.classList.add('highlighted'); - } else { - element.classList.remove('highlighted'); - } - } - if (enable) { - tr.classList.add('highlighted'); - } else { - tr.classList.remove('highlighted'); - } - } - }; -}; - -var doubleEscapeHTML = function(unsafe) { - return escapeHTML(escapeHTML(unsafe)); -}; - -var escapeHTML = function(unsafe) { - return unsafe.replace(/[&<>"']/g, function(m) { - switch (m) { - case '&': - return '&'; - case '<': - return '<'; - case '>': - return '>'; - case '"': - return '"'; - default: - return '''; - } - }); -}; +/* + * The MIT License + * + * Copyright (c) 2010, Manufacture Française des Pneumatiques Michelin, Thomas Maurel + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +function debounce(func, timeout = 300) { + let timer; + return (...args) => { + clearTimeout(timer); + timer = setTimeout(() => { func.apply(this, args); }, timeout); + }; +} + +var doubleEscapeHTML = function(unsafe) { + return escapeHTML(escapeHTML(unsafe)); +}; + +var toQueryString = function(params) { + return '?' + new URLSearchParams(params).toString(); +}; + +function getPreviousSiblings(elem) { + const sibs = []; + while (elem = elem.previousSibling) { + if (elem.nodeType === 3) continue; + sibs.push(elem); + } + return sibs; +} + +/* TableHighlighter - used by Permission Templates page */ +class TableHighlighter { + constructor(id, decalx) { + this.table = document.getElementById(id); + this.decalx = decalx; + const trs = this.table.querySelectorAll('tbody tr'); + for (const row of trs) { + this.scan(row); + } + } + + scan(tr) { + const descendants = tr.querySelectorAll('.rsp-highlight-input'); + for (const td of descendants) { + td.addEventListener('mouseenter', this.highlight); + td.addEventListener('mouseleave', this.highlight); + } + const stopNodes = tr.querySelectorAll("div.rsp-remove"); + const lastStop = stopNodes[stopNodes.length - 1]; + if (lastStop != null) { + const td = lastStop.closest('td'); + td.addEventListener('mouseenter', this.highlightRowOnly); + td.addEventListener('mouseleave', this.highlightRowOnly); + } + } + + highlightRowOnly = (e) => { + const enable = e.type === 'mouseenter'; + const tr = e.target.closest("TR"); + tr.classList.toggle('highlighted', enable); + } + + highlight = (e) => { + const enable = e.type === 'mouseenter'; + if (e.target.tagName === 'TD') { + const td = e.target; + const tr = td.parentNode; + const trs = this.table.querySelectorAll('tr.highlight-row'); + const position = getPreviousSiblings(td).length; + + let p = 0; + for (const row of trs) { + let num = position; + if (p === 0) num = num - this.decalx; + p++; + const element = row.childNodes[num]; + if (element) element.classList.toggle('highlighted', enable); + } + tr.classList.toggle('highlighted', enable); + } + } +} diff --git a/src/main/webapp/js/tableManage.js b/src/main/webapp/js/tableManage.js index 06d88a3f..1b04a96a 100644 --- a/src/main/webapp/js/tableManage.js +++ b/src/main/webapp/js/tableManage.js @@ -1,7 +1,7 @@ /* * The MIT License * - * Copyright (c) 2022, Markus Winter + * Copyright (c) 2022-2026, Markus Winter, Tim Jacomb * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -22,441 +22,1279 @@ * THE SOFTWARE. */ -// number of lines required for the role filter to get enabled -var filterLimit = 10; -// number of lines required for the footer to get displayed -var footerLimit = 20; -var globalTableHighlighter; -var newGlobalRoleTemplate; -var projectTableHighlighter; -var newItemRoleTemplate; -var agentTableHighlighter; -var newAgentRoleTemplate; - - -function filterRows(filter, table) { - for (let row of table.tBodies[0].rows) { - let userCell = row.cells[1].textContent.toUpperCase(); - if (userCell.indexOf(filter) > -1) { - row.style.display = ""; - } else { - row.style.display = "none"; - } - } -} +// Assignment data per section: { globalRoles: [...], projectRoles: [...], slaveRoles: [...] } +const rspAssignmentData = {}; + +// ============================================ +// Card expand/collapse +// ============================================ + +const rspToggleCard = (card) => { + const body = card.querySelector(".rsp-card__body"); + const header = card.querySelector(".rsp-card__header"); + if (!body || !header) return; + const isExpanded = !body.classList.contains("rsp-card__body--collapsed"); + body.classList.toggle("rsp-card__body--collapsed"); + header.setAttribute("aria-expanded", String(!isExpanded)); + card.setAttribute("aria-expanded", String(!isExpanded)); -getPattern = function(row) { - let pattern = ""; - patternEditInputs = row.getElementsByClassName("patternEdit"); - if (patternEditInputs.length > 0) { - pattern = patternEditInputs[0].value; + // Lazy-load assignments on first expand + if (!isExpanded) { + const assignSection = card.querySelector(".rsp-assign[data-loaded='false']"); + if (assignSection) { + rspLoadAssignmentsForCard(card); + } } - return pattern; -} +}; +// ============================================ +// Permission summary +// ============================================ -Behaviour.specify(".row-input-filter", "RoleBasedAuthorizationStrategy", 0, function(e) { - e.onkeyup = debounce((event) => { - if (ignoreKeys(event.code)) { - return; +const rspBuildSummary = (card) => { + const body = card.querySelector(".rsp-card__body"); + if (!body) return ""; + + const groups = body.querySelectorAll(".rsp-perm__group"); + const parts = []; + groups.forEach((group) => { + const title = group.querySelector(".rsp-perm__group-title"); + if (!title) return; + const checked = []; + group.querySelectorAll("input[type=checkbox]").forEach((cb) => { + const label = cb.closest(".rsp-perm__item"); + const isImplied = label?.classList.contains("rsp-perm__item--implied"); + if (cb.checked && !isImplied) { + const nameEl = label ? label.querySelector(".rsp-perm__item-name") : null; + if (nameEl) checked.push(nameEl.textContent.trim()); + } + }); + if (checked.length > 0) { + parts.push(title.textContent.trim() + ": " + checked.join(", ")); } - let filter = e.value.toUpperCase(); - let table = document.getElementById(e.getAttribute("data-table-id")); - filterRows(filter, table); }); -}); - + return parts.join(" \u00B7 "); +}; -Behaviour.specify("svg.icon-pencil", 'RoleBasedAuthorizationStrategy', 0, function(e) { - e.onclick = handlePatternEdit; -}); +const rspUpdateSummary = (card) => { + const summaryEl = card.querySelector(".rsp-card__summary"); + if (!summaryEl) return; -handlePatternEdit = function() { - let span = this.nextSibling; - div = span.childNodes[0]; - input = span.childNodes[1]; - if (span.getAttribute("data-edit") === "false") { - span.setAttribute("data-edit", "true"); - div.style.display = "none"; - input.type = "text"; - input.setAttribute("size", input.value.length); - input.onkeydown = handleKey; - const end = input.value.length; - input.setSelectionRange(end, end); - input.focus(); + const summary = rspBuildSummary(card); + if (summary) { + summaryEl.textContent = summary; + summaryEl.classList.remove("rsp-card__summary--empty"); } else { - endPatternInput(span, false); - this.blur(); - } - return false; -} - -handleKey = function(e) { - let key = e.key || 0; - if (key == "Enter" || key === "Escape") { - e.preventDefault(); - e.stopImmediatePropagation(); - let span = e.target.parentNode; - endPatternInput(span, key === "Escape"); + summaryEl.textContent = summaryEl.getAttribute("data-empty-text") || "No permissions"; + summaryEl.classList.add("rsp-card__summary--empty"); } }; +// ============================================ +// Implied permissions +// ============================================ -endPatternInput = function(span, cancel) { - let div = span.childNodes[0]; - let input = span.childNodes[1]; - let pattern = input.value; - let table = span.closest("TABLE"); - input.type = "hidden"; - div.style.display = "block"; - span.setAttribute("data-edit", "false"); - if (cancel) { - input.value = div.getAttribute("data-pattern"); - } else { - div.setAttribute("data-pattern", pattern); - div.textContent = '"' + pattern + '"' - let row = span.closest("TR"); - for (td of row.getElementsByClassName('permissionInput')) { - updateTooltip(row, td, pattern); +const rspUpdateImplied = (card) => { + const labels = card.querySelectorAll(".rsp-perm__item[data-permission-id]"); + labels.forEach((label) => { + const checkbox = label.querySelector("input[type=checkbox]"); + if (!checkbox) return; + + // Skip template-disabled checkboxes + if (label.closest(".rsp-card[data-template-name]")?.dataset.templateName) { + return; } - Behaviour.applySubtree(row, true); - } -} - - -updateTooltip = function(tr, td, pattern) { - let tooltipTemplate = td.getAttribute("data-tooltip-template"); - let impliedByString = td.getAttribute('data-implied-by-list'); - let impliedByList = impliedByString.split(" "); - let input = td.getElementsByTagName('INPUT')[0]; - input.disabled = false; - let disableCheckboxes = td.getAttribute("data-disable-checkboxes"); - if (disableCheckboxes === 'true') { - input.disabled = true; - } - let tooltip = tooltipTemplate.replace("{{PATTERNTEMPLATE}}", escapeHTML(pattern)).replace("{{GRANTBYOTHER}}", ""); - input.nextSibling.setAttribute("data-html-tooltip", tooltip); + const impliedByStr = label.getAttribute("data-implied-by-list"); + if (!impliedByStr || impliedByStr.trim() === "") return; + + const impliedByList = impliedByStr.trim().split(" "); + let isImplied = false; - for (let permissionId of impliedByList) { - let reference = tr.querySelector("td[data-permission-id='" + permissionId + "'] input"); - if (reference !== null) { - if (reference.checked) { - input.disabled = true; - tooltip = tooltipTemplate.replace("{{PATTERNTEMPLATE}}", escapeHTML(pattern)).replace("{{GRANTBYOTHER}}", " is granted through another permission");; - input.nextSibling.setAttribute("data-html-tooltip", tooltip); // 2.335+ + for (const permId of impliedByList) { + const ref = card.querySelector(`.rsp-perm__item[data-permission-id='${permId}'] input[type=checkbox]`); + if (ref && ref.checked) { + isImplied = true; + break; } } - } - if (window.registerTooltips) { - window.registerTooltips(e.nextSibling.parentElement); - } -} - - -Behaviour.specify( - ".role-strategy-add-button", "RoleBasedAuthorizationStrategy", 0, - function(elem) { - elem.onclick = function(e) { - let tableId = elem.getAttribute("data-table-id"); - let table = document.getElementById(tableId); - let templateId = elem.getAttribute("data-template-id"); - let highlighter = window[elem.getAttribute("data-highlighter")]; - addButtonAction(e, templateId, table, highlighter, tableId); - let tbody = table.tBodies[0]; - if (tbody.children.length >= filterLimit) { - let rolefilters = document.querySelectorAll(".row-filter"); - for (let filter of rolefilters) { - if (filter.getAttribute("data-table-id") === tableId) { - filter.style.display = "block"; - } + const impliedLabel = label.querySelector(".rsp-perm__item-implied"); + if (isImplied) { + checkbox.checked = false; + checkbox.disabled = true; + label.classList.add("rsp-perm__item--implied"); + if (impliedLabel) impliedLabel.hidden = false; + } else { + checkbox.disabled = false; + label.classList.remove("rsp-perm__item--implied"); + if (impliedLabel) impliedLabel.hidden = true; + } + }); +}; + +// ============================================ +// Search and filter +// ============================================ + +const rspApplyFilters = (container) => { + const searchInput = container.querySelector(".rsp-search input"); + const query = searchInput ? searchInput.value.toLowerCase().trim() : ""; + + const activePermissions = []; + container.querySelectorAll(".rsp-filter__item--active").forEach((item) => { + activePermissions.push(item.getAttribute("data-filter-permission")); + }); + + const cards = container.querySelectorAll(".rsp-card"); + let visibleCount = 0; + + cards.forEach((card) => { + const roleName = (card.getAttribute("data-role-name") || "").toLowerCase(); + const pattern = (card.getAttribute("data-role-pattern") || "").toLowerCase(); + const matchesText = query === "" || roleName.includes(query) || pattern.includes(query); + + let matchesPermission = activePermissions.length === 0; + if (!matchesPermission) { + for (const permId of activePermissions) { + const cb = card.querySelector(`.rsp-perm__item[data-permission-id='${permId}'] input[type=checkbox]`); + if (cb && (cb.checked || cb.disabled)) { + matchesPermission = true; + break; } } - if (tbody.children.length >= footerLimit) { - table.tFoot.style.display = "table-footer-group"; - } } + + if (matchesText && matchesPermission) { + card.classList.remove("rsp-card--hidden"); + visibleCount++; + } else { + card.classList.add("rsp-card--hidden"); + } + }); + + const emptyState = container.querySelector(".rsp-empty-state"); + if (emptyState) { + emptyState.hidden = visibleCount > 0 || (query === "" && activePermissions.length === 0); } -); - -addButtonAction = function(e, templateId, table, tableHighlighter, tableId) { - let tbody = table.tBodies[0]; - let roleInput = document.getElementById(tableId + 'text') - let name = roleInput.value.trim(); - if (name == "") { - alert("Please enter a role name"); - return; + + const hasActiveFilters = activePermissions.length > 0; + const filterBtn = container.querySelector(".rsp-filter__button"); + if (filterBtn) { + filterBtn.classList.toggle("rsp-filter__button--active", hasActiveFilters); } - if (findElementsBySelector(tbody, "TR").find(function(n) { - return n.getAttribute("name") == '[' + name + ']'; - }) != null) { - alert("Entry for '" + name + "' already exists"); - return; + const resetBtn = container.querySelector(".rsp-filter__reset-button"); + if (resetBtn) { + resetBtn.hidden = !hasActiveFilters; } - let pattern = ""; - let template = window[templateId].content.firstElementChild.cloneNode(true); - let templateName = ""; - - if (tableId !== "globalRoles") { - let patternInput = document.getElementById(tableId + 'pattern') - pattern = patternInput.value; - if (pattern == "") { - alert("Please enter a pattern"); - return; +}; + +// ============================================ +// Assignments +// ============================================ + +const rspGenerateSVGIcon = (iconName) => { + const icons = document.querySelector("#assign-roles-icons"); + return icons.content.querySelector(`#${iconName}`).cloneNode(true); +}; + +const rspCreateAssignChip = (name, type, container, roleName) => { + const chip = document.createElement("span"); + chip.classList.add("rsp-assign__chip"); + if (type === "EITHER") chip.classList.add("rsp-assign__chip--either"); + chip.dataset.name = name; + chip.dataset.type = type; + + // Icon + const iconSpan = document.createElement("span"); + iconSpan.classList.add("rsp-assign__chip-icon"); + const iconName = (type === "GROUP" || (type === "EITHER")) ? "rsp-people-icon" : "rsp-person-icon"; + iconSpan.appendChild(rspGenerateSVGIcon(iconName)); + chip.appendChild(iconSpan); + + // Name + const dataHolder = document.getElementById("role-strategy-data"); + let displayName = name; + if (name === "anonymous" && type === "USER") { + displayName = dataHolder.dataset.textAnonymous; + } else if (name === "authenticated" && type === "GROUP") { + displayName = dataHolder.dataset.textAuthenticated; + } + chip.appendChild(document.createTextNode(displayName)); + + // Remove button + const isReadOnly = container.closest(".rsp-card")?.classList.contains("rsp-card--read-only"); + if (!isReadOnly) { + // Migration buttons for EITHER type + if (type === "EITHER") { + const migrateUser = document.createElement("button"); + migrateUser.type = "button"; + migrateUser.classList.add("rsp-assign__chip-migrate"); + migrateUser.title = "Migrate to user"; + migrateUser.dataset.migrateType = "USER"; + migrateUser.appendChild(rspGenerateSVGIcon("rsp-person-icon")); + migrateUser.addEventListener("click", (e) => { + e.stopPropagation(); + rspMigrateAssignment(chip, "USER", roleName, container); + }); + chip.appendChild(migrateUser); + + const migrateGroup = document.createElement("button"); + migrateGroup.type = "button"; + migrateGroup.classList.add("rsp-assign__chip-migrate"); + migrateGroup.title = "Migrate to group"; + migrateGroup.dataset.migrateType = "GROUP"; + migrateGroup.appendChild(rspGenerateSVGIcon("rsp-people-icon")); + migrateGroup.addEventListener("click", (e) => { + e.stopPropagation(); + rspMigrateAssignment(chip, "GROUP", roleName, container); + }); + chip.appendChild(migrateGroup); } - if (tableId === "projectRoles") { - let templateSelect = document.getElementById(tableId + 'template'); - let templateName = templateSelect.value; - if (templateName !== '') { - template = window[templateId + "-" + templateName].content.firstElementChild.cloneNode(true); - } + + const removeBtn = document.createElement("button"); + removeBtn.type = "button"; + removeBtn.classList.add("rsp-assign__chip-remove"); + removeBtn.innerHTML = "×"; + removeBtn.title = `Remove ${name}`; + const isBuiltIn = (name === "anonymous" && type === "USER") || (name === "authenticated" && type === "GROUP"); + if (isBuiltIn) { + removeBtn.style.display = "none"; } + removeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + rspRemoveAssignment(chip, roleName, container); + }); + chip.appendChild(removeBtn); } - let copy = document.importNode(template, true); - let child = copy.childNodes[1]; - child.textContent = name; - if (tableId !== "globalRoles") { - let doubleQuote = '"'; - copy.getElementsByClassName("patternAnchor")[0].textContent = doubleQuote + pattern + doubleQuote; - copy.getElementsByClassName("patternEdit")[0].value = pattern; + return chip; +}; + +const rspMigrateAssignment = (chip, newType, roleName, assignContainer) => { + const sectionId = assignContainer.closest(".rsp-container").id; + const assignType = assignContainer.closest(".rsp-container").dataset.assignType; + const json = rspAssignmentData[assignType]; + if (!json) return; + + const oldName = chip.dataset.name; + const oldType = chip.dataset.type; + + // Find the entry for this user across all roles + for (const entry of json) { + if (entry.name === oldName && entry.type === oldType) { + entry.type = newType; + break; + } } - let children = copy.childNodes - children.forEach(function(item){ - item.outerHTML= item.outerHTML.replace(/{{ROLE}}/g, doubleEscapeHTML(name)).replace(/{{PATTERN}}/g, doubleEscapeHTML(pattern)); + // Refresh assignments display for all cards in this section + const container = assignContainer.closest(".rsp-container"); + container.querySelectorAll(".rsp-assign[data-loaded='true']").forEach((section) => { + rspRenderAssignments(section, section.dataset.roleName, assignType); }); - if (tableId !== "globalRoles") { - spanElement = copy.childNodes[2].childNodes[0].childNodes[1]; - if (tableId === "projectRoles") { - bindListenerToPattern(spanElement.childNodes[0]); - } else { - bindAgentListenerToPattern(spanElement.childNodes[0]); + rspMarkDirty(); +}; + +const rspRemoveAssignment = (chip, roleName, assignContainer) => { + const assignType = assignContainer.closest(".rsp-container").dataset.assignType; + const json = rspAssignmentData[assignType]; + if (!json) return; + + const name = chip.dataset.name; + const type = chip.dataset.type; + + // Remove role from the entry's roles array + for (const entry of json) { + if (entry.name === name && entry.type === type) { + const idx = entry.roles.indexOf(roleName); + if (idx !== -1) entry.roles.splice(idx, 1); + break; } } - copy.setAttribute("name", '[' + name + ']'); - if (tableId !== "globalRoles") { - copy.querySelector("svg.icon-pencil").onclick = handlePatternEdit; + chip.remove(); + rspMarkDirty(); +}; + +const rspRenderAssignments = (assignSection, roleName, assignType) => { + const json = rspAssignmentData[assignType]; + if (!json) return; + + const list = assignSection.querySelector(".rsp-assign__list"); + list.innerHTML = ""; + + // Find all entries that have this role + const entries = json.filter((entry) => entry.roles.includes(roleName)); + + // Sort: anonymous first, authenticated second, then alphabetical + entries.sort((a, b) => { + if (a.name === "anonymous" && a.type === "USER") return -1; + if (b.name === "anonymous" && b.type === "USER") return 1; + if (a.name === "authenticated" && a.type === "GROUP") return -1; + if (b.name === "authenticated" && b.type === "GROUP") return 1; + return a.name.localeCompare(b.name); + }); + + if (entries.length === 0) { + const empty = document.createElement("span"); + empty.classList.add("rsp-assign__loading"); + empty.textContent = "No users or groups assigned"; + list.appendChild(empty); + return; } - tbody.appendChild(copy); - tableHighlighter.scan(copy); - Behaviour.applySubtree(copy.closest("TABLE"), true); -} - - -Behaviour.specify(".role-strategy-table .rsp-remove", 'RoleBasedAuthorizationStrategy', 0, function(e) { - e.onclick = function() { - let table = this.closest("TABLE"); - let tableId = table.getAttribute("id"); - let tr = this.closest("TR"); - parent = tr.parentNode; - parent.removeChild(tr); - if (parent.children.length < filterLimit) { - let userfilters = document.querySelectorAll(".row-filter") - for (let filter of userfilters) { - if (filter.getAttribute("data-table-id") === tableId) { - filter.style.display = "none"; - let inputs = filter.getElementsByTagName("INPUT"); - inputs[0].value = "" - let event = new Event("keyup"); - inputs[0].dispatchEvent(event); - } - } - } - if (parent.children.length < footerLimit) { - table.tFoot.style.display = "none"; + + const maxVisible = 10; + let visibleCount = 0; + const hiddenChips = []; + + entries.forEach((entry) => { + const chip = rspCreateAssignChip(entry.name, entry.type, assignSection, roleName); + if (visibleCount >= maxVisible) { + chip.style.display = "none"; + hiddenChips.push(chip); } - let dirtyButton = document.getElementById("rs-dirty-indicator"); - dirtyButton.dispatchEvent(new Event('click')); - return false; + list.appendChild(chip); + visibleCount++; + }); + + if (hiddenChips.length > 0) { + const showMore = document.createElement("button"); + showMore.type = "button"; + showMore.classList.add("rsp-assign__show-more"); + showMore.textContent = `Show all (${entries.length})`; + showMore.addEventListener("click", () => { + hiddenChips.forEach((c) => { c.style.display = ""; }); + showMore.remove(); + }); + list.appendChild(showMore); } -}); -Behaviour.specify(".role-strategy-table td.permissionInput input", 'RoleBasedAuthorizationStrategy', 0, function(e) { - let table = e.closest("TABLE"); - if (table.classList.contains('read-only')) { - // if this is a read-only UI (ExtendedRead / SystemRead), do not enable checkboxes + assignSection.dataset.loaded = "true"; +}; + +const rspLoadAssignmentsForCard = (card) => { + const container = card.closest(".rsp-container"); + const assignType = container.dataset.assignType; + const roleName = card.dataset.roleName; + const assignSection = card.querySelector(".rsp-assign"); + if (!assignSection) return; + + if (rspAssignmentData[assignType]) { + rspRenderAssignments(assignSection, roleName, assignType); return; } - let row = e.closest("TR"); - let pattern = getPattern(row); - let td = e.closest("TD"); - updateTooltip(row, td, pattern); - e.onchange = function() { - Behaviour.applySubtree(row.closest("TABLE"), true); - return true; - }; -}); + // Need to fetch + const dataHolder = document.getElementById("role-strategy-data"); + const fetchUrl = dataHolder.dataset.fetchUrl; + const params = new URLSearchParams({ type: assignType }); + fetch(fetchUrl + "?" + params) + .then((rsp) => rsp.json()) + .then((json) => { + // Ensure anonymous and authenticated exist + rspEnsureFixedEntry(json, "anonymous", "USER"); + rspEnsureFixedEntry(json, "authenticated", "GROUP"); + rspAssignmentData[assignType] = json; + rspRenderAssignments(assignSection, roleName, assignType); + }) + .catch(() => { + const list = assignSection.querySelector(".rsp-assign__list"); + list.innerHTML = "Failed to load assignments"; + }); +}; -// methods for item roles -showMatchingProjects = function() { - let pattern = this.textContent.substring(1, this.textContent.length - 1); // Ignore quotes for the pattern - let maxItems = 15; // Maximum items to search for - let url = 'strategy/getMatchingJobs'; - reqParams = { - 'pattern': pattern, - 'maxJobs': maxItems +const rspEnsureFixedEntry = (json, name, type) => { + const existing = json.find((e) => e.name === name && e.type === type); + if (!existing) { + json.push({ name, type, roles: [] }); } +}; - fetch(url + toQueryString(reqParams)).then((rsp) => { - if (rsp.ok) { - rsp.json().then((responseJson) => { - let matchingItems = responseJson.matchingJobs; - let itemCount = responseJson.itemCount; +// ============================================ +// Add assignment +// ============================================ - if (matchingItems != null) { - showItemsModal(matchingItems, itemCount, maxItems, pattern); - } else { - showErrorMessageModal(); +const rspAddAssignmentToRole = (card, name, type) => { + const container = card.closest(".rsp-container"); + const assignType = container.dataset.assignType; + const roleName = card.dataset.roleName; + const json = rspAssignmentData[assignType]; + if (!json) return; + + // Find or create entry + let entry = json.find((e) => e.name === name && e.type === type); + if (!entry) { + entry = { name, type, roles: [] }; + json.push(entry); + } + + if (!entry.roles.includes(roleName)) { + entry.roles.push(roleName); + } + + // Re-render + const assignSection = card.querySelector(".rsp-assign"); + if (assignSection) { + rspRenderAssignments(assignSection, roleName, assignType); + } + rspMarkDirty(); +}; + +// ============================================ +// Save +// ============================================ + +const rspCollectRolesData = () => { + const result = {}; + + document.querySelectorAll(".rsp-container").forEach((container) => { + const roleType = container.dataset.roleType; + if (!roleType) return; + + const roles = {}; + container.querySelectorAll(".rsp-card").forEach((card) => { + const roleName = card.dataset.roleName; + const roleData = {}; + + // Pattern + const pattern = card.dataset.rolePattern; + if (pattern !== undefined && pattern !== "") { + roleData.pattern = pattern; + } + + // Template name + const templateName = card.dataset.templateName; + if (templateName) { + roleData.templateName = templateName; + } + + // Permissions + card.querySelectorAll(".rsp-perm__item input[type=checkbox]").forEach((cb) => { + const permId = cb.getAttribute("data-permission-id"); + if (permId && (cb.checked || cb.disabled)) { + // For implied (disabled but not checked), we don't include them + // Only include explicitly checked permissions + if (cb.checked) { + roleData[permId] = true; + } } }); - } else { - showErrorMessageModal(); + + roles[roleName] = roleData; + }); + + result[roleType] = { data: roles }; + }); + + return result; +}; + +const rspSave = (redirect) => { + const dataHolder = document.getElementById("role-strategy-data"); + + // Save roles + const rolesData = rspCollectRolesData(); + const rolesFormData = new FormData(); + rolesFormData.append("json", JSON.stringify(rolesData)); + + // Ensure all assignment types have data (use empty arrays for unloaded sections) + document.querySelectorAll(".rsp-container").forEach((container) => { + const assignType = container.dataset.assignType; + if (assignType && !rspAssignmentData[assignType]) { + rspAssignmentData[assignType] = []; } }); -} -showItemsModal = function(items, itemCount, maxItems, pattern) { - let modalTitle = ''; + // Save assignments + const assignFormData = new FormData(); + const assignMapping = { rolesMapping: rspAssignmentData }; + assignFormData.append("json", JSON.stringify(assignMapping)); - if (items.length > 0) { - if (itemCount > items.length) { - modalTitle += 'First ' + maxItems + ' items (out of ' + itemCount + ') matching'; - } else { - modalTitle += 'Items matching'; + const headers = crumb.wrap({}); + + // Sequential: roles first, then assignments + return fetch(dataHolder.dataset.rolesSubmitUrl, { + method: "POST", + headers, + body: rolesFormData, + }) + .then((rsp) => { + if (!rsp.ok) throw new Error("Failed to save roles"); + return fetch(dataHolder.dataset.assignSubmitUrl, { + method: "POST", + headers, + body: assignFormData, + }); + }) + .then((rsp) => { + if (!rsp.ok) throw new Error("Failed to save assignments"); + return true; + }); +}; + +const rspMarkDirty = () => { + const dirtyButton = document.getElementById("rs-dirty-indicator"); + if (dirtyButton) { + dirtyButton.dispatchEvent(new Event("click")); + } +}; + +// ============================================ +// Pattern matching (show matching jobs/agents) +// ============================================ + +const rspShowMatchingItems = (pattern, roleType) => { + const dataHolder = document.getElementById("role-strategy-data"); + const isAgent = roleType === "slaveRoles"; + const url = isAgent ? dataHolder.dataset.matchingAgentsUrl : dataHolder.dataset.matchingJobsUrl; + const maxItems = isAgent ? 10 : 15; + const paramKey = isAgent ? "maxAgents" : "maxJobs"; + const params = { pattern, [paramKey]: maxItems }; + + fetch(url + toQueryString(params)) + .then((rsp) => rsp.ok ? rsp.json() : Promise.reject()) + .then((json) => { + const items = isAgent ? json.matchingAgents : json.matchingJobs; + const count = json.itemCount; + if (items == null) { + dialog.alert("Unable to fetch matching items."); + return; + } + let title = ""; + if (items.length > 0) { + title = count > items.length + ? `First ${maxItems} items (out of ${count}) matching` + : "Items matching"; + } else { + title = "No items found matching"; + } + title += ` "${pattern}"`; + + const el = document.createElement("div"); + items.forEach((item) => { + el.appendChild(document.createTextNode("- " + item)); + el.appendChild(document.createElement("br")); + }); + dialog.modal(el, { title }); + }) + .catch(() => { + dialog.alert("Unable to fetch matching items."); + }); +}; + +// ============================================ +// Add new role +// ============================================ + +const rspAddNewRole = (container) => { + const sectionId = container.id; + const isGlobal = sectionId === "globalRoles"; + + const isProject = sectionId === "projectRoles"; + + // Build dialog content + const content = document.createElement("div"); + content.classList.add("rsp-dialog-content"); + + // Role name field + const nameGroup = document.createElement("div"); + nameGroup.classList.add("jenkins-form-item"); + nameGroup.innerHTML = ` + +
+ +
+ `; + content.appendChild(nameGroup); + + // Pattern field (non-global only) + if (!isGlobal) { + const patternGroup = document.createElement("div"); + patternGroup.classList.add("jenkins-form-item"); + patternGroup.innerHTML = ` + +
+ +
+ `; + content.appendChild(patternGroup); + } + + // Template selector (item roles only) + if (isProject) { + const templateStr = container.dataset.templates || ""; + const templateNames = templateStr ? templateStr.split(",").map((s) => s.trim()).filter(Boolean) : []; + + if (templateNames.length > 0) { + const templateGroup = document.createElement("div"); + templateGroup.classList.add("jenkins-form-item"); + + const options = templateNames.map((n) => ``).join(""); + templateGroup.innerHTML = ` + +
+
+ +
+
+ `; + content.appendChild(templateGroup); } - } else { - modalTitle = 'No items found matching'; } - modalTitle += ' "' + pattern + '"'; - showModal(modalTitle, items) -} - -showErrorMessageModal = function() { - dialog.alert('Unable to fetch matching Jobs.'); -} - -bindListenerToPattern = function(elem) { - elem.addEventListener('click', showMatchingProjects); -} - - -// methods for agent roles -showMatchingAgents = function() { - let pattern = this.textContent.substring(1, this.textContent.length - 1); // Ignore quotes for the pattern - let maxAgents = 10; // Maximum agents to search for - let url = 'strategy/getMatchingAgents'; - reqParams = { - 'pattern': pattern, - 'maxAgents': maxAgents + + // Permission selection - clone from existing card + let permClone = null; + const existingCard = container.querySelector(".rsp-card"); + if (existingCard) { + const permSection = existingCard.querySelector(".rsp-perm"); + if (permSection) { + permClone = permSection.cloneNode(true); + permClone.querySelectorAll("input[type=checkbox]").forEach((cb) => { + cb.checked = false; + cb.disabled = false; + cb.removeAttribute("data-initialized"); + }); + permClone.querySelectorAll(".rsp-perm__item--implied").forEach((el) => { + el.classList.remove("rsp-perm__item--implied"); + }); + permClone.querySelectorAll(".rsp-perm__item-implied").forEach((el) => { + el.hidden = true; + }); + + const permWrapper = document.createElement("div"); + permWrapper.classList.add("jenkins-form-item"); + const permTitle = document.createElement("label"); + permTitle.classList.add("jenkins-form-label"); + permTitle.textContent = "Permissions"; + permWrapper.appendChild(permTitle); + permWrapper.appendChild(permClone); + content.appendChild(permWrapper); + + // Wire up implied permissions within the dialog + const updateDialogImplied = () => { + permClone.querySelectorAll(".rsp-perm__item[data-permission-id]").forEach((label) => { + const innerCb = label.querySelector("input[type=checkbox]"); + if (!innerCb) return; + const impliedByStr = label.getAttribute("data-implied-by-list"); + if (!impliedByStr || impliedByStr.trim() === "") return; + const impliedByList = impliedByStr.trim().split(" "); + let isImplied = false; + for (const permId of impliedByList) { + const ref = permClone.querySelector(`.rsp-perm__item[data-permission-id='${permId}'] input[type=checkbox]`); + if (ref && ref.checked) { isImplied = true; break; } + } + const impliedLabel = label.querySelector(".rsp-perm__item-implied"); + if (isImplied) { + innerCb.checked = false; + innerCb.disabled = true; + label.classList.add("rsp-perm__item--implied"); + if (impliedLabel) impliedLabel.hidden = false; + } else { + innerCb.disabled = false; + label.classList.remove("rsp-perm__item--implied"); + if (impliedLabel) impliedLabel.hidden = true; + } + }); + }; + permClone.querySelectorAll("input[type=checkbox]").forEach((cb) => { + cb.addEventListener("change", updateDialogImplied); + }); + } } - fetch(url + toQueryString(reqParams)).then((rsp) => { - if (rsp.ok) { - rsp.json().then((responseJson) => { - let matchingAgents = responseJson.matchingAgents; - let agentCount = responseJson.itemCount; + // Build native dialog + const dlg = document.createElement("dialog"); + dlg.classList.add("jenkins-dialog"); + dlg.style.cssText = "max-width:550px;min-width:450px;"; + + const titleBar = document.createElement("div"); + titleBar.classList.add("jenkins-dialog__title"); + titleBar.innerHTML = `Add Role + `; + dlg.appendChild(titleBar); - if (matchingAgents != null) { - showAgentsModal(matchingAgents, agentCount, maxAgents, pattern); + const body = document.createElement("div"); + body.classList.add("jenkins-dialog__contents"); + body.appendChild(content); + dlg.appendChild(body); + + const footer = document.createElement("div"); + footer.classList.add("jenkins-dialog__footer", "jenkins-buttons-row", "jenkins-buttons-row--equal-width"); + footer.innerHTML = ` + + + `; + dlg.appendChild(footer); + + // Wire up template selector to disable/enable permissions + const templateSelect = dlg.querySelector("#rsp-dialog-role-template"); + if (templateSelect && permClone) { + templateSelect.addEventListener("change", () => { + const isTemplate = templateSelect.value !== ""; + permClone.querySelectorAll("input[type=checkbox]").forEach((cb) => { + if (isTemplate) { + cb.checked = false; + cb.disabled = true; } else { - showAgentErrorMessageModal(); + cb.disabled = false; } }); - } else { - showAgentErrorMessageModal(); - } + permClone.style.opacity = isTemplate ? "0.5" : "1"; + permClone.style.pointerEvents = isTemplate ? "none" : ""; + }); + } + + document.body.appendChild(dlg); + dlg.showModal(); + + const closeDialog = () => { + dlg.close(); + dlg.remove(); + }; + + dlg.querySelectorAll("[data-id='cancel']").forEach((btn) => { + btn.addEventListener("click", closeDialog); }); -} -showAgentsModal = function(agents, agentCount, maxAgents, pattern) { - let modalTitle = ''; - if (agents.length > 0) { - if (agentCount > agents.length) { - modalTitle += 'First ' + maxAgents + ' agents (out of ' + agentCount + ') matching'; - } else { - modalTitle += 'Agents matching'; + dlg.addEventListener("cancel", closeDialog); + + dlg.querySelector("[data-id='ok']").addEventListener("click", () => { + const nameInput = dlg.querySelector("#rsp-dialog-role-name"); + const name = nameInput?.value?.trim(); + if (!name) { + nameInput?.focus(); + return; } - } else { - modalTitle += 'No Agents found matching'; + + // Check duplicate + const existing = container.querySelector(`.rsp-card[data-role-name='${CSS.escape(name)}']`); + if (existing) { + closeDialog(); + dialog.alert(`A role named "${name}" already exists.`); + return; + } + + let pattern = ""; + if (!isGlobal) { + const patternInput = dlg.querySelector("#rsp-dialog-role-pattern"); + pattern = patternInput?.value?.trim(); + if (!pattern) { + patternInput?.focus(); + return; + } + } + + // Template selection (item roles) + let templateName = ""; + const templateSelect = dlg.querySelector("#rsp-dialog-role-template"); + if (templateSelect) { + templateName = templateSelect.value; + } + + // Collect selected permissions + const selectedPermissions = new Set(); + dlg.querySelectorAll(".rsp-perm__item input[type=checkbox]:checked").forEach((cb) => { + const permId = cb.getAttribute("data-permission-id"); + if (permId) selectedPermissions.add(permId); + }); + + closeDialog(); + rspCreateRoleCard(container, name, pattern, templateName, selectedPermissions); + }); +}; + +const rspCreateRoleCard = (container, name, pattern, templateName, initialPermissions = new Set()) => { + const cardsContainer = container.querySelector(".rsp-cards"); + const sectionId = container.id; + + // Find an existing card to clone as template + const existingCard = container.querySelector(".rsp-card"); + + const card = document.createElement("div"); + card.classList.add("rsp-card"); + card.dataset.roleName = name; + card.dataset.rolePattern = pattern; + card.dataset.templateName = templateName; + card.dataset.sectionId = sectionId; + + // Build header + const header = document.createElement("div"); + header.classList.add("rsp-card__header"); + header.setAttribute("role", "button"); + header.setAttribute("tabindex", "0"); + header.setAttribute("aria-expanded", "false"); + + const nameSpan = document.createElement("span"); + nameSpan.classList.add("rsp-card__name"); + nameSpan.textContent = name; + header.appendChild(nameSpan); + + if (pattern) { + const patternSpan = document.createElement("span"); + patternSpan.classList.add("rsp-card__pattern"); + patternSpan.dataset.pattern = pattern; + patternSpan.dataset.roleType = container.dataset.roleType; + patternSpan.textContent = `"${pattern}"`; + header.appendChild(patternSpan); } - modalTitle += ' "' + pattern + '"'; - showModal(modalTitle, agents) -} - -showModal = function(title, itemlist) { - messageElement=document.createElement("div"); - for (let item of itemlist) { - line = document.createTextNode("- " + item); - messageElement.appendChild(line); - messageElement.appendChild(document.createElement("br")); + + const summarySpan = document.createElement("span"); + summarySpan.classList.add("rsp-card__summary", "rsp-card__summary--empty"); + summarySpan.dataset.emptyText = "No permissions"; + summarySpan.textContent = "No permissions"; + header.appendChild(summarySpan); + + // Actions + const actions = document.createElement("div"); + actions.classList.add("rsp-card__actions"); + + if (pattern) { + const editBtn = document.createElement("button"); + editBtn.type = "button"; + editBtn.classList.add("jenkins-button", "jenkins-button--tertiary", "rsp-card__action", "rsp-card__edit-pattern"); + editBtn.setAttribute("tooltip", "Edit pattern"); + editBtn.innerHTML = ''; + actions.appendChild(editBtn); } - dialog.modal(messageElement, {title: title}); -} -showAgentErrorMessageModal = function() { - dialogalert('Unable to fetch matching Agents.'); -} + const deleteBtn = document.createElement("button"); + deleteBtn.type = "button"; + deleteBtn.classList.add("jenkins-button", "jenkins-button--tertiary", "jenkins-!-destructive-color", "rsp-card__action", "rsp-card__delete"); + deleteBtn.setAttribute("tooltip", "Delete role"); + deleteBtn.innerHTML = ''; + actions.appendChild(deleteBtn); + header.appendChild(actions); -bindAgentListenerToPattern = function(elem) { - elem.addEventListener('click', showMatchingAgents); -} + // Toggle chevron + const toggle = document.createElement("div"); + toggle.classList.add("rsp-card__toggle"); + toggle.innerHTML = ''; + header.appendChild(toggle); -document.addEventListener('DOMContentLoaded', function() { + card.appendChild(header); - // global roles initialization - const globalTable = document.getElementById("globalRoles"); - if (globalTable) { - const readOnly = globalTable.classList.contains("read-only"); + // Body - clone permission groups from an existing card + const body = document.createElement("div"); + body.classList.add("rsp-card__body", "rsp-card__body--collapsed"); - let globalRoleInputFilter = document.getElementById('globalRoleInputFilter'); - if (globalRoleInputFilter && parseInt(globalRoleInputFilter.getAttribute("data-initial-size")) >= filterLimit) { - globalRoleInputFilter.style.display = "block" + if (existingCard) { + const existingPerm = existingCard.querySelector(".rsp-perm"); + if (existingPerm) { + const permClone = existingPerm.cloneNode(true); + // Set initial permissions + permClone.querySelectorAll("input[type=checkbox]").forEach((cb) => { + const permId = cb.getAttribute("data-permission-id"); + cb.checked = initialPermissions.has(permId); + cb.disabled = false; + }); + permClone.querySelectorAll(".rsp-perm__item--implied").forEach((el) => { + el.classList.remove("rsp-perm__item--implied"); + }); + permClone.querySelectorAll(".rsp-perm__item-implied").forEach((el) => { + el.hidden = true; + }); + body.appendChild(permClone); } - newGlobalRoleTemplate = document.getElementById('newGlobalRoleTemplate'); - - globalTableHighlighter = new TableHighlighter('globalRoles', readOnly ? 1 : 2); } - // item roles initialization - const projectRolesTable = document.getElementById('projectRoles'); - if (projectRolesTable) { - const readOnly = projectRolesTable.classList.contains("read-only"); + // Assignment section + const assignDiv = document.createElement("div"); + assignDiv.classList.add("rsp-assign"); + assignDiv.dataset.roleName = name; + assignDiv.dataset.loaded = "false"; + assignDiv.innerHTML = ` +

Assigned Users and Groups

+
+ No users or groups assigned +
+
+ + +
+ `; + body.appendChild(assignDiv); + + card.appendChild(body); + cardsContainer.appendChild(card); + + // Apply behaviors + Behaviour.applySubtree(card, true); - let itemRoleInputFilter = document.getElementById('itemRoleInputFilter'); - if (itemRoleInputFilter && parseInt(itemRoleInputFilter.getAttribute("data-initial-size")) >= filterLimit ) { - itemRoleInputFilter.style.display = "block" + // Expand the new card + rspToggleCard(card); + card.classList.add("rsp-highlight-entry"); + + rspMarkDirty(); +}; + +// ============================================ +// Behaviours +// ============================================ + +// Card header click +Behaviour.specify(".rsp-card__header", "RoleStrategyCards", 0, (header) => { + if (header.dataset.initialized === "true") return; + header.dataset.initialized = "true"; + + const handleToggle = (e) => { + if (e.target.closest(".rsp-card__actions") && !e.target.closest(".rsp-card__toggle")) return; + const card = header.closest(".rsp-card"); + if (card && !card.classList.contains("rsp-card--read-only")) { + rspToggleCard(card); } + }; - newItemRoleTemplate = document.getElementById('newItemRoleTemplate'); + header.addEventListener("click", handleToggle); + header.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleToggle(e); + } + }); +}); - projectTableHighlighter = new TableHighlighter('projectRoles', readOnly ? 3 : 4); +// Permission checkbox change +Behaviour.specify(".rsp-perm__item input[type=checkbox]", "RoleStrategyCards", 0, (cb) => { + if (cb.dataset.initialized === "true") return; + cb.dataset.initialized = "true"; - // Show jobs matching a pattern on click - let itemPatterns = projectRolesTable.getElementsByClassName('patternAnchor'); - for (let pattern of itemPatterns) { - bindListenerToPattern(pattern); + cb.addEventListener("change", () => { + const card = cb.closest(".rsp-card"); + if (card) { + rspUpdateImplied(card); + rspUpdateSummary(card); + rspMarkDirty(); } - } + }); +}); + +// Initialize summaries and implied on page load +Behaviour.specify(".rsp-card", "RoleStrategyCards", 1, (card) => { + if (card.dataset.summaryInitialized === "true") return; + card.dataset.summaryInitialized = "true"; + rspUpdateImplied(card); + rspUpdateSummary(card); +}); + +// Delete role +Behaviour.specify(".rsp-card__delete", "RoleStrategyCards", 0, (btn) => { + if (btn.dataset.initialized === "true") return; + btn.dataset.initialized = "true"; + + btn.addEventListener("click", (e) => { + e.stopPropagation(); + const card = btn.closest(".rsp-card"); + if (!card) return; + const roleName = card.dataset.roleName; + + dialog.confirm("Delete role", { + message: `Are you sure you want to delete the role "${roleName}"?`, + type: "destructive", + }).then(() => { + // Remove assignments for this role + const container = card.closest(".rsp-container"); + const assignType = container.dataset.assignType; + const json = rspAssignmentData[assignType]; + if (json) { + json.forEach((entry) => { + const idx = entry.roles.indexOf(roleName); + if (idx !== -1) entry.roles.splice(idx, 1); + }); + } + card.remove(); + rspMarkDirty(); + }).catch(() => {}); + }); +}); + +// Edit pattern +Behaviour.specify(".rsp-card__edit-pattern", "RoleStrategyCards", 0, (btn) => { + if (btn.dataset.initialized === "true") return; + btn.dataset.initialized = "true"; + + btn.addEventListener("click", (e) => { + e.stopPropagation(); + const card = btn.closest(".rsp-card"); + if (!card) return; + const currentPattern = card.dataset.rolePattern || ""; + + dialog.prompt("Edit pattern", { + message: "Enter the new pattern:", + defaultValue: currentPattern, + }).then((newPattern) => { + if (newPattern === null || newPattern === undefined) return; + newPattern = newPattern.trim(); + if (!newPattern) return; + + card.dataset.rolePattern = newPattern; + const patternSpan = card.querySelector(".rsp-card__pattern"); + if (patternSpan) { + patternSpan.textContent = `"${newPattern}"`; + patternSpan.dataset.pattern = newPattern; + } + rspMarkDirty(); + }).catch(() => {}); + }); +}); + +// Pattern click - show matching items +Behaviour.specify(".rsp-card__pattern", "RoleStrategyCards", 0, (span) => { + if (span.dataset.initialized === "true") return; + span.dataset.initialized = "true"; + + span.addEventListener("click", (e) => { + // Don't trigger if we're clicking during expand/collapse + if (e.target.closest(".rsp-card__actions")) return; + e.stopPropagation(); + const pattern = span.dataset.pattern; + const roleType = span.closest(".rsp-container")?.dataset.roleType; + if (pattern && roleType) { + rspShowMatchingItems(pattern, roleType); + } + }); +}); + +// Add role button +Behaviour.specify(".rsp-add-role", "RoleStrategyCards", 0, (btn) => { + if (btn.dataset.initialized === "true") return; + btn.dataset.initialized = "true"; + + btn.addEventListener("click", () => { + const sectionId = btn.dataset.sectionId; + const container = document.getElementById(sectionId); + if (container) { + rspAddNewRole(container); + } + }); +}); + +// Add user/group assignment +Behaviour.specify(".rsp-assign__add", "RoleStrategyCards", 0, (btn) => { + if (btn.dataset.initialized === "true") return; + btn.dataset.initialized = "true"; + + btn.addEventListener("click", () => { + const type = btn.dataset.type; + const card = btn.closest(".rsp-card"); + if (!card) return; + + const promptText = type === "USER" ? "Enter the user name:" : "Enter the group name:"; + dialog.prompt(promptText).then((name) => { + if (!name || !name.trim()) return; + name = name.trim(); + rspAddAssignmentToRole(card, name, type); + }); + }); +}); + +// Search bar +Behaviour.specify(".rsp-search input", "RoleStrategyCards", 0, (input) => { + if (input.dataset.searchInitialized === "true") return; + input.dataset.searchInitialized = "true"; + + input.addEventListener("input", () => { + const container = input.closest(".rsp-container"); + if (container) rspApplyFilters(container); + }); +}); + +// Filter button +Behaviour.specify(".rsp-filter__button", "RoleStrategyCards", 0, (btn) => { + if (btn.dataset.filterInitialized === "true") return; + btn.dataset.filterInitialized = "true"; + + const filterEl = btn.parentElement; + const dropdown = filterEl.querySelector(".rsp-filter__dropdown"); + if (!dropdown) return; + const container = filterEl.closest(".rsp-container"); + + // Filter item click + dropdown.querySelectorAll(".rsp-filter__item").forEach((item) => { + item.addEventListener("click", () => { + item.classList.toggle("rsp-filter__item--active"); + if (container) rspApplyFilters(container); + }); + }); - // agent roles initialization - const agentRolesTable = document.getElementById('agentRoles'); - if (agentRolesTable) { - const readOnly = agentRolesTable.classList.contains("read-only"); + // Search within dropdown + const filterSearchInput = dropdown.querySelector(".rsp-filter__search-input"); + const filterSearchClear = dropdown.querySelector(".rsp-filter__search-clear"); - newAgentRoleTemplate = document.getElementById('newAgentRoleTemplate'); + const applyFilterSearch = () => { + const q = filterSearchInput ? filterSearchInput.value.toLowerCase().trim() : ""; + const groupTitles = dropdown.querySelectorAll(".rsp-filter__group-title"); + + dropdown.querySelectorAll(".rsp-filter__item").forEach((item) => { + const label = (item.getAttribute("data-filter-label") || "").toLowerCase(); + item.classList.toggle("rsp-filter__item--filter-hidden", q !== "" && !label.includes(q)); + }); + + groupTitles.forEach((title) => { + let next = title.nextElementSibling; + let hasVisible = false; + while (next && !next.classList.contains("rsp-filter__group-title")) { + if (next.classList.contains("rsp-filter__item") && !next.classList.contains("rsp-filter__item--filter-hidden")) { + hasVisible = true; + } + next = next.nextElementSibling; + } + title.classList.toggle("rsp-filter__group-title--filter-hidden", !hasVisible); + }); - agentTableHighlighter = new TableHighlighter('agentRoles', readOnly ? 2 : 3); - // Show agents matching a pattern on click - let agentPatterns = agentRolesTable.getElementsByClassName('patternAnchor'); - for (let pattern of agentPatterns) { - bindAgentListenerToPattern(pattern); + if (filterSearchClear) { + filterSearchClear.classList.toggle("rsp-filter__search-clear--visible", q !== ""); } + const noResults = dropdown.querySelector(".rsp-filter__no-results"); + if (noResults) { + const hasAnyVisible = dropdown.querySelector(".rsp-filter__item:not(.rsp-filter__item--filter-hidden)"); + noResults.hidden = !!hasAnyVisible; + } + }; + + if (filterSearchInput) { + filterSearchInput.addEventListener("input", applyFilterSearch); + filterSearchInput.addEventListener("click", (e) => e.stopPropagation()); + } + + if (filterSearchClear) { + filterSearchClear.addEventListener("click", (e) => { + e.stopPropagation(); + filterSearchInput.value = ""; + applyFilterSearch(); + filterSearchInput.focus(); + }); } -}); \ No newline at end of file + + // Reset button + const resetBtn = dropdown.querySelector(".rsp-filter__reset-button"); + if (resetBtn) { + resetBtn.addEventListener("click", (e) => { + e.preventDefault(); + dropdown.querySelectorAll(".rsp-filter__item--active").forEach((item) => { + item.classList.remove("rsp-filter__item--active"); + }); + if (filterSearchInput) filterSearchInput.value = ""; + applyFilterSearch(); + if (container) rspApplyFilters(container); + }); + } + + // Toggle dropdown + btn.addEventListener("click", (e) => { + e.stopPropagation(); + // Close all other open filter dropdowns + document.querySelectorAll(".rsp-filter__dropdown").forEach((d) => { + if (d !== dropdown) d.hidden = true; + }); + document.querySelectorAll(".rsp-filter__button").forEach((b) => { + if (b !== btn) b.setAttribute("aria-expanded", "false"); + }); + const isOpen = !dropdown.hidden; + dropdown.hidden = isOpen; + btn.setAttribute("aria-expanded", String(!isOpen)); + + if (!isOpen) { + const closeDropdown = () => { + dropdown.hidden = true; + btn.setAttribute("aria-expanded", "false"); + document.removeEventListener("click", clickHandler); + document.removeEventListener("keydown", escHandler); + }; + const clickHandler = (evt) => { + if (!dropdown.contains(evt.target) && evt.target !== btn) closeDropdown(); + }; + const escHandler = (evt) => { + if (evt.key === "Escape") { + closeDropdown(); + btn.focus(); + } + }; + setTimeout(() => { + document.addEventListener("click", clickHandler); + document.addEventListener("keydown", escHandler); + }, 0); + } + }); +}); + +// Save button +Behaviour.specify("#rsp-save", "RoleStrategyCards", 0, (button) => { + button.addEventListener("click", () => { + rspSave(true).then(() => { + window.location.reload(); + }).catch((err) => { + notificationBar.show("Failed to save: " + err.message, notificationBar.ERROR); + }); + }); +}); + +// Apply button +Behaviour.specify("#rsp-apply", "RoleStrategyCards", 0, (button) => { + button.addEventListener("click", () => { + rspSave(false).then(() => { + notificationBar.show(button.dataset.message, notificationBar.SUCCESS); + }).catch((err) => { + notificationBar.show("Failed to apply: " + err.message, notificationBar.ERROR); + }); + }); +}); + + +// ============================================ +// Initialization - eagerly load all assignment data +// ============================================ + +const rspLoadAllAssignments = () => { + const dataHolder = document.getElementById("role-strategy-data"); + if (!dataHolder) return; + + const fetchUrl = dataHolder.dataset.fetchUrl; + if (!fetchUrl) return; + + document.querySelectorAll(".rsp-container").forEach((container) => { + const assignType = container.dataset.assignType; + if (!assignType || rspAssignmentData[assignType]) return; + + const params = new URLSearchParams({ type: assignType }); + fetch(fetchUrl + "?" + params) + .then((rsp) => rsp.json()) + .then((json) => { + rspEnsureFixedEntry(json, "anonymous", "USER"); + rspEnsureFixedEntry(json, "authenticated", "GROUP"); + rspAssignmentData[assignType] = json; + }) + .catch(() => { + // Initialize with empty array to prevent save failures + rspAssignmentData[assignType] = []; + }); + }); +}; + +document.addEventListener("DOMContentLoaded", rspLoadAllAssignments); + +// ============================================ +// Collapsible sections +// ============================================ + +Behaviour.specify(".rsp-container", "RoleStrategyCollapsible", 0, (container) => { + const section = container.closest(".jenkins-section"); + if (!section || section.dataset.collapsibleInit === "true") return; + section.dataset.collapsibleInit = "true"; + section.classList.add("rsp-section-collapsible"); + + const title = section.querySelector(".jenkins-section__title"); + if (!title) return; + + title.addEventListener("click", () => { + section.classList.toggle("rsp-section--collapsed"); + }); +}); diff --git a/src/main/webapp/js/tableTemplates.js b/src/main/webapp/js/tableTemplates.js index 9377cf93..cfa590e1 100644 --- a/src/main/webapp/js/tableTemplates.js +++ b/src/main/webapp/js/tableTemplates.js @@ -94,7 +94,7 @@ Behaviour.specify( ); addButtonAction = function (e, template, table, tableHighlighter, tableId) { - let dataReference = e.target; + let dataReference = e.target.closest("[data-prompt]") || e.target; let tbody = table.tBodies[0]; dialog.prompt(dataReference.getAttribute('data-prompt')).then((name) => { @@ -105,18 +105,17 @@ addButtonAction = function (e, template, table, tableHighlighter, tableId) { } let copy = document.importNode(template,true); - let child = copy.childNodes[1]; - child.textContent = escapeHTML(name); - - let children = copy.getElementsByClassName("permissionInput"); - for (let child of children) { - if (child.hasAttribute('data-tooltip-template')) { - child.setAttribute("data-tooltip-template", child.getAttribute("data-tooltip-template").replace(/{{TEMPLATE}}/g, doubleEscapeHTML(name))); - } + // Set the name in the first visible text cell + const nameCell = copy.querySelector(".left-most") || copy.childNodes[1]; + if (nameCell) { + nameCell.textContent = escapeHTML(name); } - if (tableId !== "permissionTemplates") { - spanElement = copy.childNodes[2].childNodes[0].childNodes[1]; + let permInputs = copy.getElementsByClassName("permissionInput"); + for (let cell of permInputs) { + if (cell.hasAttribute('data-tooltip-template')) { + cell.setAttribute("data-tooltip-template", cell.getAttribute("data-tooltip-template").replace(/\{\{TEMPLATE\}\}/g, doubleEscapeHTML(name))); + } } copy.setAttribute("name",'['+name+']'); @@ -125,7 +124,9 @@ addButtonAction = function (e, template, table, tableHighlighter, tableId) { tableHighlighter.scan(copy); } Behaviour.applySubtree(copy.closest("TABLE"), true); - }) + }).catch(() => { + // Dialog was cancelled + }); } function deleteTemplate(button) { From 6685eb47c25b37dea6b9f624517a5957a6fa32a1 Mon Sep 17 00:00:00 2001 From: Tim Jacomb Date: Sat, 4 Apr 2026 08:56:00 +0100 Subject: [PATCH 02/88] Fixes --- .../RoleStrategyConfig/index.jelly | 8 +++-- .../manage-project-roles.jelly | 7 +++++ src/main/webapp/css/role-strategy.css | 20 ++++++------ src/main/webapp/js/tableManage.js | 31 +++++++++++++++++-- 4 files changed, 50 insertions(+), 16 deletions(-) diff --git a/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/index.jelly b/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/index.jelly index ce79a3f4..fa5c2704 100644 --- a/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/index.jelly +++ b/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/index.jelly @@ -47,6 +47,7 @@ +
@@ -62,7 +63,8 @@
- + @@ -82,6 +84,7 @@ +
+ role="${role}" isTemplated="${isTemplated ? 'true' : 'false'}" + permissionTemplate="${permissionTemplate}"/>

${%Assigned Users and Groups}

diff --git a/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/manage-project-roles.jelly b/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/manage-project-roles.jelly index dd9e4b0e..2280fc09 100644 --- a/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/manage-project-roles.jelly +++ b/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/manage-project-roles.jelly @@ -15,6 +15,13 @@
+ + + + + diff --git a/src/main/webapp/css/role-strategy.css b/src/main/webapp/css/role-strategy.css index e1262047..f622bf6f 100644 --- a/src/main/webapp/css/role-strategy.css +++ b/src/main/webapp/css/role-strategy.css @@ -648,21 +648,19 @@ button.rsp-filter__button--active { user-select: none; } -.rsp-section-collapsible .jenkins-section__title::after { - content: ""; +.rsp-section-collapsible .jenkins-section__title .rsp-section-chevron { display: inline-block; - width: 0.5rem; - height: 0.5rem; - border-right: 2px solid var(--text-color-secondary); - border-bottom: 2px solid var(--text-color-secondary); - transform: rotate(45deg); - margin-left: 0.5rem; + width: 1em; + height: 1em; + vertical-align: text-top; + margin-left: 0.25rem; transition: transform 0.2s; - vertical-align: middle; + color: var(--text-color-secondary); } -.rsp-section-collapsible.rsp-section--collapsed .jenkins-section__title::after { - transform: rotate(-45deg); +.rsp-section-collapsible.rsp-section--collapsed .rsp-section-chevron { + transform: rotate(-90deg); + vertical-align: text-bottom; } .rsp-section-collapsible.rsp-section--collapsed .rsp-container, diff --git a/src/main/webapp/js/tableManage.js b/src/main/webapp/js/tableManage.js index 1b04a96a..fd4dfdaa 100644 --- a/src/main/webapp/js/tableManage.js +++ b/src/main/webapp/js/tableManage.js @@ -847,6 +847,13 @@ const rspCreateRoleCard = (container, name, pattern, templateName, initialPermis header.appendChild(patternSpan); } + if (templateName) { + const templateBadge = document.createElement("span"); + templateBadge.classList.add("rsp-card__template-badge"); + templateBadge.textContent = templateName; + header.appendChild(templateBadge); + } + const summarySpan = document.createElement("span"); summarySpan.classList.add("rsp-card__summary", "rsp-card__summary--empty"); summarySpan.dataset.emptyText = "No permissions"; @@ -890,11 +897,23 @@ const rspCreateRoleCard = (container, name, pattern, templateName, initialPermis const existingPerm = existingCard.querySelector(".rsp-perm"); if (existingPerm) { const permClone = existingPerm.cloneNode(true); - // Set initial permissions + + // If template selected, load its permissions from embedded JSON + let templatePerms = initialPermissions; + if (templateName) { + const templateData = container.querySelector(`.rsp-template-data[data-template-name='${CSS.escape(templateName)}']`); + if (templateData) { + try { + const permIds = JSON.parse(templateData.textContent); + templatePerms = new Set(permIds); + } catch (e) { /* ignore parse errors */ } + } + } + permClone.querySelectorAll("input[type=checkbox]").forEach((cb) => { const permId = cb.getAttribute("data-permission-id"); - cb.checked = initialPermissions.has(permId); - cb.disabled = false; + cb.checked = templatePerms.has(permId); + cb.disabled = !!templateName; // disable all if template-based }); permClone.querySelectorAll(".rsp-perm__item--implied").forEach((el) => { el.classList.remove("rsp-perm__item--implied"); @@ -1294,6 +1313,12 @@ Behaviour.specify(".rsp-container", "RoleStrategyCollapsible", 0, (container) => const title = section.querySelector(".jenkins-section__title"); if (!title) return; + // Add chevron SVG + const chevron = document.createElement("span"); + chevron.classList.add("rsp-section-chevron"); + chevron.innerHTML = ''; + title.appendChild(chevron); + title.addEventListener("click", () => { section.classList.toggle("rsp-section--collapsed"); }); From 415f2b08ff6e645ff657d91a018742f6c90dd4fa Mon Sep 17 00:00:00 2001 From: Tim Jacomb Date: Sat, 4 Apr 2026 09:56:13 +0100 Subject: [PATCH 03/88] Minor change --- src/main/webapp/js/tableManage.js | 42 +++++++++++++------------------ 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/src/main/webapp/js/tableManage.js b/src/main/webapp/js/tableManage.js index fd4dfdaa..9a66d217 100644 --- a/src/main/webapp/js/tableManage.js +++ b/src/main/webapp/js/tableManage.js @@ -860,33 +860,27 @@ const rspCreateRoleCard = (container, name, pattern, templateName, initialPermis summarySpan.textContent = "No permissions"; header.appendChild(summarySpan); - // Actions - const actions = document.createElement("div"); - actions.classList.add("rsp-card__actions"); + // Actions — clone from existing card to get correct icon paths + if (existingCard) { + const existingActions = existingCard.querySelector(".rsp-card__actions"); + if (existingActions) { + const actions = existingActions.cloneNode(true); + // Remove edit-pattern button if this role has no pattern + if (!pattern) { + const editBtn = actions.querySelector(".rsp-card__edit-pattern"); + if (editBtn) editBtn.remove(); + } + // Reset initialized flags so behaviours re-bind + actions.querySelectorAll("[data-initialized]").forEach((el) => el.removeAttribute("data-initialized")); + header.appendChild(actions); + } - if (pattern) { - const editBtn = document.createElement("button"); - editBtn.type = "button"; - editBtn.classList.add("jenkins-button", "jenkins-button--tertiary", "rsp-card__action", "rsp-card__edit-pattern"); - editBtn.setAttribute("tooltip", "Edit pattern"); - editBtn.innerHTML = ''; - actions.appendChild(editBtn); + const existingToggle = existingCard.querySelector(".rsp-card__toggle"); + if (existingToggle) { + header.appendChild(existingToggle.cloneNode(true)); + } } - const deleteBtn = document.createElement("button"); - deleteBtn.type = "button"; - deleteBtn.classList.add("jenkins-button", "jenkins-button--tertiary", "jenkins-!-destructive-color", "rsp-card__action", "rsp-card__delete"); - deleteBtn.setAttribute("tooltip", "Delete role"); - deleteBtn.innerHTML = ''; - actions.appendChild(deleteBtn); - header.appendChild(actions); - - // Toggle chevron - const toggle = document.createElement("div"); - toggle.classList.add("rsp-card__toggle"); - toggle.innerHTML = ''; - header.appendChild(toggle); - card.appendChild(header); // Body - clone permission groups from an existing card From a8779a8388699f302291b61483e3ab0ba96cad4b Mon Sep 17 00:00:00 2001 From: Tim Jacomb Date: Sat, 4 Apr 2026 10:56:44 +0100 Subject: [PATCH 04/88] Assign role button --- .../RoleStrategyConfig/index.jelly | 12 +- src/main/webapp/css/role-strategy.css | 44 +++ src/main/webapp/js/tableManage.js | 268 ++++++++++++++++-- 3 files changed, 302 insertions(+), 22 deletions(-) diff --git a/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/index.jelly b/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/index.jelly index fa5c2704..ecfbe181 100644 --- a/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/index.jelly +++ b/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/index.jelly @@ -226,6 +226,12 @@ + + + ${%Permission Templates} @@ -261,12 +267,6 @@ - - - - - - diff --git a/src/main/webapp/css/role-strategy.css b/src/main/webapp/css/role-strategy.css index f622bf6f..57258774 100644 --- a/src/main/webapp/css/role-strategy.css +++ b/src/main/webapp/css/role-strategy.css @@ -713,6 +713,50 @@ button.rsp-filter__button--active { padding: 0.375rem 0.375rem 0 0.375rem; } +/* ============================================ + Assign Role Dialog + ============================================ */ + +.rsp-assign-dialog__roles { + max-height: 300px; + overflow-y: auto; + border: var(--jenkins-border); + border-radius: var(--form-input-border-radius); + padding: 0.25rem 0; +} + +.rsp-assign-dialog__group-title { + font-weight: var(--font-bold-weight); + font-size: 0.6875rem; + color: var(--text-color-secondary); + text-transform: uppercase; + letter-spacing: 0.03em; + padding: 0.5rem 0.75rem 0.25rem; +} + +.rsp-assign-dialog__role { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.75rem; + cursor: pointer; + transition: background var(--standard-transition); +} + +.rsp-assign-dialog__role:hover { + background: var(--item-background--hover); +} + +.rsp-assign-dialog__role-name { + font-size: 0.875rem; + color: var(--text-color); +} + +.rsp-assign-dialog__role-pattern { + font-size: 0.8125rem; + color: var(--text-color-secondary); +} + /* ============================================ Ambiguity warning ============================================ */ diff --git a/src/main/webapp/js/tableManage.js b/src/main/webapp/js/tableManage.js index 9a66d217..56b37f3b 100644 --- a/src/main/webapp/js/tableManage.js +++ b/src/main/webapp/js/tableManage.js @@ -543,6 +543,17 @@ const rspMarkDirty = () => { } }; +// Auto-save after a short debounce to batch rapid changes +let rspAutoSaveTimer = null; +const rspAutoSave = () => { + if (rspAutoSaveTimer) clearTimeout(rspAutoSaveTimer); + rspAutoSaveTimer = setTimeout(() => { + rspSave(false).catch((err) => { + notificationBar.show("Failed to save: " + err.message, notificationBar.ERROR); + }); + }, 1000); +}; + // ============================================ // Pattern matching (show matching jobs/agents) // ============================================ @@ -809,6 +820,13 @@ const rspAddNewRole = (container) => { closeDialog(); rspCreateRoleCard(container, name, pattern, templateName, selectedPermissions); + + // Save immediately and reload + rspSave(false).then(() => { + window.location.reload(); + }).catch((err) => { + notificationBar.show("Failed to save role: " + err.message, notificationBar.ERROR); + }); }); }; @@ -985,7 +1003,7 @@ Behaviour.specify(".rsp-perm__item input[type=checkbox]", "RoleStrategyCards", 0 if (card) { rspUpdateImplied(card); rspUpdateSummary(card); - rspMarkDirty(); + rspAutoSave(); } }); }); @@ -1024,7 +1042,12 @@ Behaviour.specify(".rsp-card__delete", "RoleStrategyCards", 0, (btn) => { }); } card.remove(); - rspMarkDirty(); + // Save immediately + rspSave(false).then(() => { + notificationBar.show(`Role "${roleName}" deleted`, notificationBar.SUCCESS); + }).catch((err) => { + notificationBar.show("Failed to save: " + err.message, notificationBar.ERROR); + }); }).catch(() => {}); }); }); @@ -1054,7 +1077,7 @@ Behaviour.specify(".rsp-card__edit-pattern", "RoleStrategyCards", 0, (btn) => { patternSpan.textContent = `"${newPattern}"`; patternSpan.dataset.pattern = newPattern; } - rspMarkDirty(); + rspAutoSave(); }).catch(() => {}); }); }); @@ -1239,26 +1262,239 @@ Behaviour.specify(".rsp-filter__button", "RoleStrategyCards", 0, (btn) => { }); }); -// Save button -Behaviour.specify("#rsp-save", "RoleStrategyCards", 0, (button) => { - button.addEventListener("click", () => { - rspSave(true).then(() => { - window.location.reload(); - }).catch((err) => { - notificationBar.show("Failed to save: " + err.message, notificationBar.ERROR); +// ============================================ +// Assign role dialog +// ============================================ + +const rspAssignRoleDialog = () => { + const content = document.createElement("div"); + content.classList.add("rsp-dialog-content"); + + // Name field + const nameGroup = document.createElement("div"); + nameGroup.classList.add("jenkins-form-item"); + nameGroup.innerHTML = ` + +
+ +
+ `; + content.appendChild(nameGroup); + + // Type selection + const typeGroup = document.createElement("div"); + typeGroup.classList.add("jenkins-form-item"); + typeGroup.innerHTML = ` + +
+ + +
+ `; + content.appendChild(typeGroup); + + // Role selection with filter + const roleGroup = document.createElement("div"); + roleGroup.classList.add("jenkins-form-item"); + + const roleLabel = document.createElement("label"); + roleLabel.classList.add("jenkins-form-label"); + roleLabel.textContent = "Roles"; + roleGroup.appendChild(roleLabel); + + const roleFilter = document.createElement("input"); + roleFilter.type = "text"; + roleFilter.classList.add("jenkins-input"); + roleFilter.placeholder = "Filter roles..."; + roleFilter.style.cssText = "margin-bottom:0.5rem;"; + roleGroup.appendChild(roleFilter); + + const roleList = document.createElement("div"); + roleList.classList.add("rsp-assign-dialog__roles"); + + // Build role checkboxes from DOM + const sectionLabels = { globalRoles: "Global roles", projectRoles: "Item roles", slaveRoles: "Agent roles" }; + + document.querySelectorAll(".rsp-container").forEach((container) => { + const assignType = container.dataset.assignType; + const cards = container.querySelectorAll(".rsp-card"); + if (cards.length === 0) return; + + const groupTitle = document.createElement("div"); + groupTitle.classList.add("rsp-assign-dialog__group-title"); + groupTitle.textContent = sectionLabels[assignType] || assignType; + roleList.appendChild(groupTitle); + + cards.forEach((card) => { + const roleName = card.dataset.roleName; + const pattern = card.dataset.rolePattern; + const label = document.createElement("label"); + label.classList.add("rsp-assign-dialog__role"); + label.dataset.roleName = roleName; + label.dataset.assignType = assignType; + + const cb = document.createElement("input"); + cb.type = "checkbox"; + cb.value = roleName; + cb.dataset.assignType = assignType; + label.appendChild(cb); + + const nameSpan = document.createElement("span"); + nameSpan.classList.add("rsp-assign-dialog__role-name"); + nameSpan.textContent = roleName; + label.appendChild(nameSpan); + + if (pattern) { + const patternSpan = document.createElement("span"); + patternSpan.classList.add("rsp-assign-dialog__role-pattern"); + patternSpan.textContent = `"${pattern}"`; + label.appendChild(patternSpan); + } + + roleList.appendChild(label); + }); + }); + + roleGroup.appendChild(roleList); + content.appendChild(roleGroup); + + // Filter behaviour + roleFilter.addEventListener("input", () => { + const q = roleFilter.value.toLowerCase().trim(); + roleList.querySelectorAll(".rsp-assign-dialog__role").forEach((label) => { + const name = label.dataset.roleName.toLowerCase(); + label.style.display = (q === "" || name.includes(q)) ? "" : "none"; + }); + roleList.querySelectorAll(".rsp-assign-dialog__group-title").forEach((title) => { + let next = title.nextElementSibling; + let hasVisible = false; + while (next && !next.classList.contains("rsp-assign-dialog__group-title")) { + if (next.style.display !== "none") hasVisible = true; + next = next.nextElementSibling; + } + title.style.display = hasVisible ? "" : "none"; }); }); -}); -// Apply button -Behaviour.specify("#rsp-apply", "RoleStrategyCards", 0, (button) => { - button.addEventListener("click", () => { + // Pre-fill if user already exists: listen for name changes + const prefillRoles = () => { + const name = document.getElementById("rsp-assign-name")?.value?.trim(); + const type = content.querySelector("input[name='rsp-assign-type']:checked")?.value; + if (!name || !type) return; + + // Check all assignment data for this user + roleList.querySelectorAll(".rsp-assign-dialog__role input[type=checkbox]").forEach((cb) => { + const assignType = cb.dataset.assignType; + const json = rspAssignmentData[assignType]; + if (!json) { cb.checked = false; return; } + const entry = json.find((e) => e.name === name && e.type === type); + cb.checked = entry ? entry.roles.includes(cb.value) : false; + }); + }; + + // Build dialog + const dlg = document.createElement("dialog"); + dlg.classList.add("jenkins-dialog"); + dlg.style.cssText = "max-width:550px;min-width:450px;"; + + const titleBar = document.createElement("div"); + titleBar.classList.add("jenkins-dialog__title"); + titleBar.innerHTML = `Assign role + `; + dlg.appendChild(titleBar); + + const body = document.createElement("div"); + body.classList.add("jenkins-dialog__contents"); + body.appendChild(content); + dlg.appendChild(body); + + const footer = document.createElement("div"); + footer.classList.add("jenkins-dialog__footer", "jenkins-buttons-row", "jenkins-buttons-row--equal-width"); + footer.innerHTML = ` + + + `; + dlg.appendChild(footer); + + document.body.appendChild(dlg); + dlg.showModal(); + + // Wire up prefill on blur of name field + const nameInput = dlg.querySelector("#rsp-assign-name"); + nameInput.addEventListener("blur", prefillRoles); + dlg.querySelectorAll("input[name='rsp-assign-type']").forEach((r) => { + r.addEventListener("change", prefillRoles); + }); + + const closeDialog = () => { dlg.close(); dlg.remove(); }; + + dlg.querySelectorAll("[data-id='cancel']").forEach((btn) => { + btn.addEventListener("click", closeDialog); + }); + dlg.addEventListener("cancel", closeDialog); + + dlg.querySelector("[data-id='ok']").addEventListener("click", () => { + const name = nameInput.value?.trim(); + if (!name) { nameInput.focus(); return; } + + const type = dlg.querySelector("input[name='rsp-assign-type']:checked").value; + + // Collect selected roles grouped by assignType + const rolesByType = {}; + dlg.querySelectorAll(".rsp-assign-dialog__role input[type=checkbox]:checked").forEach((cb) => { + const at = cb.dataset.assignType; + if (!rolesByType[at]) rolesByType[at] = []; + rolesByType[at].push(cb.value); + }); + + // Update rspAssignmentData for each type + for (const [assignType, roles] of Object.entries(rolesByType)) { + if (!rspAssignmentData[assignType]) rspAssignmentData[assignType] = []; + let entry = rspAssignmentData[assignType].find((e) => e.name === name && e.type === type); + if (!entry) { + entry = { name, type, roles: [] }; + rspAssignmentData[assignType].push(entry); + } + // Merge: add roles not already present + roles.forEach((r) => { if (!entry.roles.includes(r)) entry.roles.push(r); }); + } + + // Also handle unchecked roles (remove assignments) + dlg.querySelectorAll(".rsp-assign-dialog__role input[type=checkbox]:not(:checked)").forEach((cb) => { + const at = cb.dataset.assignType; + const json = rspAssignmentData[at]; + if (!json) return; + const entry = json.find((e) => e.name === name && e.type === type); + if (entry) { + const idx = entry.roles.indexOf(cb.value); + if (idx !== -1) entry.roles.splice(idx, 1); + } + }); + + closeDialog(); + + // Save immediately rspSave(false).then(() => { - notificationBar.show(button.dataset.message, notificationBar.SUCCESS); + // Refresh assignment chips on all expanded cards + document.querySelectorAll(".rsp-assign[data-loaded='true']").forEach((section) => { + const roleName = section.dataset.roleName; + const assignType = section.closest(".rsp-container")?.dataset.assignType; + if (assignType) rspRenderAssignments(section, roleName, assignType); + }); + notificationBar.show("Role assignment saved", notificationBar.SUCCESS); }).catch((err) => { - notificationBar.show("Failed to apply: " + err.message, notificationBar.ERROR); + notificationBar.show("Failed to save: " + err.message, notificationBar.ERROR); }); }); +}; + +// Assign role button behaviour +Behaviour.specify(".rsp-assign-role-btn", "RoleStrategyCards", 0, (btn) => { + if (btn.dataset.initialized === "true") return; + btn.dataset.initialized = "true"; + btn.addEventListener("click", rspAssignRoleDialog); }); From f617db07217bcf4464c7c09dd4b8cba588d9a40d Mon Sep 17 00:00:00 2001 From: Tim Jacomb Date: Sat, 4 Apr 2026 13:51:49 +0100 Subject: [PATCH 05/88] Manage roles page --- .../RoleStrategyConfig/index.jelly | 268 +-- .../RoleStrategyConfig/manage-roles.jelly | 223 +++ src/main/webapp/js/tableManage.js | 1664 ++++------------- src/main/webapp/js/tableRoles.js | 942 ++++++++++ 4 files changed, 1584 insertions(+), 1513 deletions(-) create mode 100644 src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/manage-roles.jelly create mode 100644 src/main/webapp/js/tableRoles.js diff --git a/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/index.jelly b/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/index.jelly index ecfbe181..0ef442a4 100644 --- a/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/index.jelly +++ b/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/index.jelly @@ -1,7 +1,7 @@ - + + + + - + +
+ + ${%Manage Roles} + ${%Permission Templates} @@ -241,32 +58,25 @@ ${%Macros}
- + +
diff --git a/src/main/webapp/js/tableManage.js b/src/main/webapp/js/tableManage.js index abd4451d..773a4871 100644 --- a/src/main/webapp/js/tableManage.js +++ b/src/main/webapp/js/tableManage.js @@ -307,7 +307,10 @@ const rspAutoSave = () => { // Search // ============================================ -const rspApplyUserSearch = () => { +// Active role filters: [{ assignType, roleName }, ...] +let rspActiveRoleFilters = []; + +const rspApplyUserFilters = () => { const input = document.querySelector(".rsp-assign-search input"); const query = input ? input.value.toLowerCase().trim() : ""; const cards = document.querySelectorAll("#rsp-user-cards .rsp-card"); @@ -315,7 +318,26 @@ const rspApplyUserSearch = () => { cards.forEach((card) => { const name = (card.dataset.userName || "").toLowerCase(); - if (query === "" || name.includes(query)) { + const userType = card.dataset.userType; + const key = `${userType}:${card.dataset.userName}`; + const userData = rspMergedUsers[key]; + + // Text match + const matchesText = query === "" || name.includes(query); + + // Role filter match (OR logic — user has ANY of the selected roles) + let matchesRole = rspActiveRoleFilters.length === 0; + if (!matchesRole && userData) { + for (const filter of rspActiveRoleFilters) { + const userRoles = userData.roles[filter.assignType] || []; + if (userRoles.includes(filter.roleName)) { + matchesRole = true; + break; + } + } + } + + if (matchesText && matchesRole) { card.classList.remove("rsp-card--hidden"); visibleCount++; } else { @@ -324,10 +346,153 @@ const rspApplyUserSearch = () => { }); const emptyState = document.getElementById("rsp-user-empty"); - if (emptyState) emptyState.hidden = visibleCount > 0 || query === ""; + if (emptyState) emptyState.hidden = visibleCount > 0 || (query === "" && rspActiveRoleFilters.length === 0); + + // Update filter button active state + const filterBtn = document.querySelector(".rsp-role-filter-btn"); + if (filterBtn) filterBtn.classList.toggle("rsp-filter__button--active", rspActiveRoleFilters.length > 0); + const resetBtn = document.querySelector(".rsp-role-filter-reset"); + if (resetBtn) resetBtn.hidden = rspActiveRoleFilters.length === 0; + rspUpdateCardBorders(); }; +const rspPopulateRoleFilter = () => { + const list = document.querySelector(".rsp-role-filter-list"); + if (!list) return; + list.innerHTML = ""; + + rspAssignTypes.forEach((type) => { + const roles = rspRoleDefinitions[type]; + if (!roles || roles.length === 0) return; + + const groupTitle = document.createElement("div"); + groupTitle.classList.add("rsp-filter__group-title"); + groupTitle.textContent = rspTypeLabels[type] + " roles"; + list.appendChild(groupTitle); + + roles.forEach((role) => { + const item = document.createElement("button"); + item.type = "button"; + item.classList.add("rsp-filter__item"); + item.dataset.assignType = type; + item.dataset.roleName = role.name; + item.dataset.filterLabel = (rspTypeLabels[type] + " " + role.name).toLowerCase(); + + item.innerHTML = ` + ${escapeHTML(role.name)}`; + + if (role.pattern) { + const patternSpan = document.createElement("span"); + patternSpan.style.cssText = "font-size:0.75rem;color:var(--text-color-secondary);margin-left:0.25rem;"; + patternSpan.textContent = `"${role.pattern}"`; + item.appendChild(patternSpan); + } + + item.addEventListener("click", () => { + item.classList.toggle("rsp-filter__item--active"); + // Rebuild active filters + rspActiveRoleFilters = []; + list.querySelectorAll(".rsp-filter__item--active").forEach((active) => { + rspActiveRoleFilters.push({ + assignType: active.dataset.assignType, + roleName: active.dataset.roleName, + }); + }); + rspApplyUserFilters(); + }); + + list.appendChild(item); + }); + }); +}; + +// Initialize role filter dropdown behaviour +const rspInitRoleFilterDropdown = () => { + const btn = document.querySelector(".rsp-role-filter-btn"); + const dropdown = document.querySelector(".rsp-role-filter-dropdown"); + if (!btn || !dropdown) return; + + const searchInput = dropdown.querySelector(".rsp-role-filter-search"); + const clearBtn = dropdown.querySelector(".rsp-role-filter-clear"); + const resetBtn = dropdown.querySelector(".rsp-role-filter-reset"); + + // Search within dropdown + const applyFilterSearch = () => { + const q = searchInput ? searchInput.value.toLowerCase().trim() : ""; + dropdown.querySelectorAll(".rsp-filter__item").forEach((item) => { + const label = item.dataset.filterLabel || ""; + item.classList.toggle("rsp-filter__item--filter-hidden", q !== "" && !label.includes(q)); + }); + dropdown.querySelectorAll(".rsp-filter__group-title").forEach((title) => { + let next = title.nextElementSibling; + let hasVisible = false; + while (next && !next.classList.contains("rsp-filter__group-title")) { + if (next.classList.contains("rsp-filter__item") && !next.classList.contains("rsp-filter__item--filter-hidden")) { + hasVisible = true; + } + next = next.nextElementSibling; + } + title.classList.toggle("rsp-filter__group-title--filter-hidden", !hasVisible); + }); + if (clearBtn) clearBtn.classList.toggle("rsp-filter__search-clear--visible", q !== ""); + }; + + if (searchInput) { + searchInput.addEventListener("input", applyFilterSearch); + searchInput.addEventListener("click", (e) => e.stopPropagation()); + } + + if (clearBtn) { + clearBtn.addEventListener("click", (e) => { + e.stopPropagation(); + searchInput.value = ""; + applyFilterSearch(); + searchInput.focus(); + }); + } + + if (resetBtn) { + resetBtn.addEventListener("click", (e) => { + e.preventDefault(); + dropdown.querySelectorAll(".rsp-filter__item--active").forEach((item) => { + item.classList.remove("rsp-filter__item--active"); + }); + rspActiveRoleFilters = []; + if (searchInput) searchInput.value = ""; + applyFilterSearch(); + rspApplyUserFilters(); + }); + } + + // Toggle dropdown + btn.addEventListener("click", (e) => { + e.stopPropagation(); + const isOpen = !dropdown.hidden; + dropdown.hidden = isOpen; + btn.setAttribute("aria-expanded", String(!isOpen)); + + if (!isOpen) { + const closeDropdown = () => { + dropdown.hidden = true; + btn.setAttribute("aria-expanded", "false"); + document.removeEventListener("click", clickHandler); + document.removeEventListener("keydown", escHandler); + }; + const clickHandler = (evt) => { + if (!dropdown.contains(evt.target) && evt.target !== btn) closeDropdown(); + }; + const escHandler = (evt) => { + if (evt.key === "Escape") { closeDropdown(); btn.focus(); } + }; + setTimeout(() => { + document.addEventListener("click", clickHandler); + document.addEventListener("keydown", escHandler); + }, 0); + } + }); +}; + // ============================================ // Assign role dialog // ============================================ @@ -637,7 +802,7 @@ Behaviour.specify(".rsp-assign-role-btn", "RoleStrategyAssign", 0, (btn) => { Behaviour.specify(".rsp-assign-search input", "RoleStrategyAssign", 0, (input) => { if (input.dataset.initialized === "true") return; input.dataset.initialized = "true"; - input.addEventListener("input", rspApplyUserSearch); + input.addEventListener("input", rspApplyUserFilters); }); // ============================================ @@ -646,6 +811,8 @@ Behaviour.specify(".rsp-assign-search input", "RoleStrategyAssign", 0, (input) = document.addEventListener("DOMContentLoaded", () => { rspLoadRoleDefinitions(); + rspPopulateRoleFilter(); + rspInitRoleFilterDropdown(); rspLoadAllAssignments().then(() => { rspMergeUsers(); rspRenderUserCards(); From 253e255f7d46dde32ea6085e031de4d7a34aa042 Mon Sep 17 00:00:00 2001 From: Tim Jacomb Date: Sat, 4 Apr 2026 14:27:02 +0100 Subject: [PATCH 11/88] Templates --- .../permission-templates.jelly | 280 ++++----- src/main/webapp/js/tableTemplates.js | 537 +++++++++++++----- 2 files changed, 534 insertions(+), 283 deletions(-) diff --git a/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/permission-templates.jelly b/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/permission-templates.jelly index 6429c4f9..d386b476 100644 --- a/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/permission-templates.jelly +++ b/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/permission-templates.jelly @@ -3,56 +3,96 @@ xmlns:l="/lib/layout" xmlns:t="/lib/hudson" xmlns:f="/lib/form" xmlns:local="local"> - + - - - - - - - - - + + + + + + + + - - - -
- -
- -
- ${title} - - - - - - - - - - + + + + + + + + + + +
+ @@ -60,115 +100,89 @@ + + - - - - + - - - - - - - - - ${%Manage Roles} - - - - ${%Permission Templates} - - - - ${%Macros} - - - - -
-
- -
- - + +
+
+ +
+ +
+
+
- -
-
${%Loading assignments...}
-
+
+
${%Loading assignments...}
+
- + - - - + + diff --git a/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/list-macros.jelly b/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/list-macros.jelly index 0bf7b3f0..361dfd5d 100644 --- a/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/list-macros.jelly +++ b/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/list-macros.jelly @@ -2,24 +2,21 @@ - - - - + + + +

+ Macros extend access rights analysis and can be used in the pattern field on the + Manage Roles page. +

+

+ Format: @macroName[:id][(param1, param2)] + — e.g. @BuildableJob, @BuildableJob:1, @Folder(folderName) +

+
+ - - - -

- Macros extend access rights analysis and can be used in the pattern field on the - Manage Roles page. -

-

- Format: @macroName[:id][(param1, param2)] - — e.g. @BuildableJob, @BuildableJob:1, @Folder(folderName) -

-
@@ -42,6 +39,5 @@
-
-
+
diff --git a/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/manage-agent-roles.jelly b/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/manage-agent-roles.jelly index 56e1455d..7d9547ec 100644 --- a/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/manage-agent-roles.jelly +++ b/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/manage-agent-roles.jelly @@ -17,6 +17,12 @@
+ + + ${%Use Add Role to create an agent role.} + + + + + + ${%Use Add Role to create a global role.} + + + + + + ${%Use Add Role to create an item role.} + + +