diff --git a/pom.xml b/pom.xml index 7a42ecc7..cb7b9eea 100644 --- a/pom.xml +++ b/pom.xml @@ -32,8 +32,8 @@ 999999-SNAPSHOT - 2.479 - ${jenkins.baseline}.3 + 2.555 + 2.560 jenkinsci/${project.artifactId}-plugin 13.0.0 640 @@ -45,7 +45,7 @@ io.jenkins.tools.bom bom-${jenkins.baseline}.x - 5054.v620b_5d2b_d5e6 + 6421.v4a_efb_4b_3a_61d pom import diff --git a/seed-data.sh b/seed-data.sh new file mode 100755 index 00000000..0bda3d83 --- /dev/null +++ b/seed-data.sh @@ -0,0 +1,201 @@ +#!/bin/bash +# Seed script: creates ~500 roles, ~150 agent roles, 2500 user assignments, 250 groups +# Usage: ./seed-data.sh [JENKINS_URL] +set -euo pipefail + +JENKINS="${1:-http://localhost:8080/jenkins}" +API="$JENKINS/role-strategy/strategy" +JENKINS_USER="${JENKINS_USERNAME}" +JENKINS_TOKEN="${JENKINS_API_TOKEN}" + +echo "=== Seeding role-strategy data at $JENKINS ===" +echo "Authenticating as $JENKINS_USER..." +curl -sf -u "$JENKINS_USER:$JENKINS_TOKEN" "$JENKINS/api/json" > /dev/null || { echo "ERROR: Cannot reach Jenkins"; exit 1; } +echo " Connected OK" + +api_post() { + local url="$1" + local http_code + http_code=$(curl -s -o /dev/null -w "%{http_code}" -X POST -u "$JENKINS_USER:$JENKINS_TOKEN" "$url") || true + [ "$http_code" = "200" ] +} + +OK=0; FAIL=0 + +# --- Global Roles (100) --- +echo "" +echo "Creating 100 global roles..." + +GLOBAL_PERMS=( + "hudson.model.Hudson.Administer" + "hudson.model.Hudson.Read" + "hudson.model.Hudson.Manage,hudson.model.Hudson.Read" + "hudson.model.Hudson.Read" + "hudson.model.Hudson.Read,hudson.model.Hudson.Manage" +) +GLOBAL_PREFIXES=("super" "senior" "junior" "lead" "staff" "principal" "associate" "chief" "deputy" "acting" "interim" "temp" "contract" "remote" "onsite" "global" "regional" "local" "external" "internal") +GLOBAL_SUFFIXES=("admin" "reader" "operator" "monitor" "engineer" "analyst" "architect" "manager" "coordinator" "specialist" "consultant" "advisor" "officer" "director" "supervisor" "technician" "developer" "tester" "auditor" "reviewer") + +for i in $(seq 0 99); do + pi=$((i / 20)) + si=$((i % 20)) + name="${GLOBAL_PREFIXES[$pi]}-${GLOBAL_SUFFIXES[$si]}" + perms="${GLOBAL_PERMS[$((i % ${#GLOBAL_PERMS[@]}))]}" + if api_post "$API/addRole?type=globalRoles&roleName=$name&permissionIds=$perms&overwrite=false"; then + ((OK++)) + else + ((FAIL++)) + fi +done +echo " Global roles: $OK ok, $FAIL fail" + +# --- Item Roles (250) --- +echo "" +echo "Creating 250 item roles..." +S0=$OK; F0=$FAIL + +ITEM_PERMS=( + "hudson.model.Item.Read,hudson.model.Item.Build" + "hudson.model.Item.Read,hudson.model.Item.Build,hudson.model.Item.Configure" + "hudson.model.Item.Read,hudson.model.Item.Build,hudson.model.Item.Configure,hudson.model.Item.Workspace" + "hudson.model.Item.Read,hudson.model.Item.Build,hudson.model.Item.Configure,hudson.model.Item.Create" + "hudson.model.Item.Read,hudson.model.Item.Build,hudson.model.Item.Configure,hudson.model.Item.Create,hudson.model.Item.Delete" + "hudson.model.Item.Read" + "hudson.model.Item.Read,hudson.model.Item.Discover" + "hudson.model.Item.Read,hudson.model.Item.Build,hudson.model.Item.Cancel" + "hudson.model.Item.Read,hudson.model.Item.Build,hudson.model.Item.Configure,hudson.model.Item.Move" + "hudson.model.Item.Read,hudson.model.Item.Build,hudson.model.Item.Workspace,hudson.model.Run.Update" +) +ITEM_PREFIXES=("frontend" "backend" "mobile" "data" "infra" "devops" "platform" "cloud" "security" "analytics" "ml" "api" "web" "desktop" "embedded" "iot" "blockchain" "ai" "microservice" "monolith" "gateway" "proxy" "cache" "queue" "stream" "batch" "realtime" "legacy" "modern" "hybrid" "serverless" "container" "native" "cross-platform" "enterprise" "startup" "fintech" "healthtech" "edtech" "gamedev" "media" "social" "commerce" "payments" "identity" "messaging" "storage" "compute" "network" "database") +ITEM_SUFFIXES=("dev" "staging" "prod" "test" "qa" "uat" "demo" "sandbox" "preview" "canary" "blue" "green" "alpha" "beta" "rc" "stable" "nightly" "weekly" "release" "hotfix" "feature" "bugfix" "spike" "poc" "mvp" "v1" "v2" "v3" "next" "current" "archive" "backup" "mirror" "primary" "secondary" "failover" "dr" "perf" "load" "stress" "smoke" "regression" "integration" "e2e" "unit" "contract" "security-scan" "lint" "build" "deploy") + +for i in $(seq 0 249); do + pi=$((i % ${#ITEM_PREFIXES[@]})) + si=$((i / ${#ITEM_PREFIXES[@]} % ${#ITEM_SUFFIXES[@]})) + name="${ITEM_PREFIXES[$pi]}-${ITEM_SUFFIXES[$si]}" + pattern="${ITEM_PREFIXES[$pi]}-${ITEM_SUFFIXES[$si]}-.*" + perms="${ITEM_PERMS[$((i % ${#ITEM_PERMS[@]}))]}" + if api_post "$API/addRole?type=projectRoles&roleName=$name&permissionIds=$perms&overwrite=false&pattern=$pattern"; then + ((OK++)) + else + ((FAIL++)) + fi +done +echo " Item roles: $((OK-S0)) ok, $((FAIL-F0)) fail" + +# --- Agent Roles (150) --- +echo "" +echo "Creating 150 agent roles..." +S0=$OK; F0=$FAIL + +AGENT_PREFIXES=("linux" "windows" "macos" "docker" "k8s" "gpu" "arm" "x86" "spot" "onprem" "cloud" "aws" "gcp" "azure" "metal" "vm" "lxc" "podman" "nerdctl" "containerd" "crio" "fargate" "ecs" "eks" "aks" "gke" "openshift" "rancher" "nomad" "mesos") +AGENT_SUFFIXES=("builder" "runner" "executor" "worker" "node" "agent" "slave" "host" "instance" "pod" "container" "machine" "server" "cluster" "pool" "fleet" "farm" "grid" "swarm" "herd" "pack" "squad" "brigade" "team" "unit" "cell" "zone" "region" "rack" "bay") + +for i in $(seq 0 149); do + pi=$((i % ${#AGENT_PREFIXES[@]})) + si=$((i / ${#AGENT_PREFIXES[@]} % ${#AGENT_SUFFIXES[@]})) + name="${AGENT_PREFIXES[$pi]}-${AGENT_SUFFIXES[$si]}" + pattern="${AGENT_PREFIXES[$pi]}-${AGENT_SUFFIXES[$si]}-.*" + if api_post "$API/addRole?type=slaveRoles&roleName=$name&permissionIds=hudson.model.Computer.Build,hudson.model.Computer.Connect&overwrite=false&pattern=$pattern"; then + ((OK++)) + else + ((FAIL++)) + fi +done +echo " Agent roles: $((OK-S0)) ok, $((FAIL-F0)) fail" + +# --- Collect role names for assignment --- +GLOBAL_ROLE_NAMES=() +for i in $(seq 0 99); do + pi=$((i / 20)); si=$((i % 20)) + GLOBAL_ROLE_NAMES+=("${GLOBAL_PREFIXES[$pi]}-${GLOBAL_SUFFIXES[$si]}") +done +ITEM_ROLE_NAMES=() +for i in $(seq 0 249); do + pi=$((i % ${#ITEM_PREFIXES[@]})); si=$((i / ${#ITEM_PREFIXES[@]} % ${#ITEM_SUFFIXES[@]})) + ITEM_ROLE_NAMES+=("${ITEM_PREFIXES[$pi]}-${ITEM_SUFFIXES[$si]}") +done +AGENT_ROLE_NAMES=() +for i in $(seq 0 149); do + pi=$((i % ${#AGENT_PREFIXES[@]})); si=$((i / ${#AGENT_PREFIXES[@]} % ${#AGENT_SUFFIXES[@]})) + AGENT_ROLE_NAMES+=("${AGENT_PREFIXES[$pi]}-${AGENT_SUFFIXES[$si]}") +done + +NUM_GLOBAL=${#GLOBAL_ROLE_NAMES[@]} +NUM_ITEM=${#ITEM_ROLE_NAMES[@]} +NUM_AGENT=${#AGENT_ROLE_NAMES[@]} + +# --- User Assignments (2500) --- +echo "" +echo "Assigning 2500 users..." +S0=$OK; F0=$FAIL + +FIRST=("James" "Mary" "Robert" "Patricia" "John" "Jennifer" "Michael" "Linda" "David" "Elizabeth" "William" "Barbara" "Richard" "Susan" "Joseph" "Jessica" "Thomas" "Sarah" "Chris" "Karen" "Charles" "Lisa" "Daniel" "Nancy" "Matt" "Betty" "Tony" "Maggie" "Mark" "Sandra" "Don" "Ashley" "Steve" "Dorothy" "Paul" "Kim" "Andrew" "Emily" "Josh" "Donna" "Ken" "Michelle" "Kevin" "Carol" "Brian" "Amanda" "George" "Melissa" "Tim" "Deborah" "Ron" "Steph" "Ed" "Rebecca" "Jason" "Sharon" "Jeff" "Laura" "Ryan" "Cynthia" "Jake" "Kate" "Gary" "Amy" "Nick" "Angela" "Eric" "Shirley" "Jon" "Anna" "Larry" "Pam" "Justin" "Emma" "Scott" "Nicole" "Brandon" "Helen" "Ben" "Sam" "Ray" "Christine" "Greg" "Debra" "Frank" "Rachel" "Alex" "Carolyn" "Pat" "Janet" "Jack" "Cath" "Dennis" "Maria" "Jerry" "Heather" "Tyler" "Diane" "Liam" "Olivia") +LAST=("Smith" "Johnson" "Williams" "Brown" "Jones" "Garcia" "Miller" "Davis" "Rodriguez" "Martinez" "Hernandez" "Lopez" "Gonzalez" "Wilson" "Anderson" "Thomas" "Taylor" "Moore" "Jackson" "Martin" "Lee" "Perez" "Thompson" "White" "Harris" "Sanchez" "Clark" "Ramirez" "Lewis" "Robinson" "Walker" "Young" "Allen" "King" "Wright" "Scott" "Torres" "Nguyen" "Hill" "Flores" "Green" "Adams" "Nelson" "Baker" "Hall" "Rivera" "Campbell" "Mitchell" "Carter" "Roberts" "Chen" "Wu" "Li" "Wang" "Zhang" "Liu" "Yang" "Huang" "Zhao" "Zhou" "Kumar" "Singh" "Patel" "Sharma" "Gupta" "Joshi" "Shah" "Mehta" "Rao" "Das" "Muller" "Schmidt" "Fischer" "Weber" "Meyer" "Becker" "Schulz" "Hoffmann" "Koch" "Richter" "Wolf" "Braun" "Durand" "Bernard" "Petit" "Robert" "Moreau" "Simon" "Laurent" "Michel" "Lefebvre" "Rossi" "Russo" "Ferrari" "Esposito" "Bianchi" "Romano" "Colombo" "Ricci" "Marino" "Greco") + +NF=${#FIRST[@]}; NL=${#LAST[@]} + +for i in $(seq 1 2500); do + fi_idx=$(( (i - 1) % NF )) + li_idx=$(( (i - 1) / NF % NL )) + username=$(echo "${FIRST[$fi_idx]:0:1}${LAST[$li_idx]}${i}" | tr '[:upper:]' '[:lower:]') + + gi=$(( i % NUM_GLOBAL )) + api_post "$API/assignUserRole?type=globalRoles&roleName=${GLOBAL_ROLE_NAMES[$gi]}&user=$username" || true + + if [ $(( i % 10 )) -lt 7 ]; then + ii=$(( i % NUM_ITEM )) + api_post "$API/assignUserRole?type=projectRoles&roleName=${ITEM_ROLE_NAMES[$ii]}&user=$username" || true + fi + + if [ $(( i % 10 )) -lt 3 ]; then + ai=$(( i % NUM_AGENT )) + api_post "$API/assignUserRole?type=slaveRoles&roleName=${AGENT_ROLE_NAMES[$ai]}&user=$username" || true + fi + + if [ $(( i % 500 )) -eq 0 ]; then + echo " $i/2500 users..." + fi +done +echo " Users done" + +# --- Group Assignments (250) --- +echo "" +echo "Assigning 250 groups..." + +DEPT=("engineering" "frontend" "backend" "devops" "qa" "security" "data" "mobile" "platform" "infra" "sre" "design" "product" "management" "hr" "finance" "legal" "marketing" "sales" "support" "research" "ml" "analytics" "compliance" "architecture") +LEVEL=("team" "squad" "guild" "chapter" "tribe" "division" "department" "unit" "group" "org") + +for i in $(seq 0 249); do + di=$(( i % ${#DEPT[@]} )) + li=$(( i / ${#DEPT[@]} % ${#LEVEL[@]} )) + group="${DEPT[$di]}-${LEVEL[$li]}" + + gi=$(( i % NUM_GLOBAL )) + api_post "$API/assignGroupRole?type=globalRoles&roleName=${GLOBAL_ROLE_NAMES[$gi]}&group=$group" || true + + if [ $(( i % 3 )) -eq 0 ]; then + ii=$(( i % NUM_ITEM )) + api_post "$API/assignGroupRole?type=projectRoles&roleName=${ITEM_ROLE_NAMES[$ii]}&group=$group" || true + fi + + if [ $(( i % 50 )) -eq 0 ]; then + echo " $((i+1))/250 groups..." + fi +done +echo " Groups done" + +# --- Verify --- +echo "" +echo "=== Verifying ===" +GLOBAL_COUNT=$(curl -sf -u "$JENKINS_USER:$JENKINS_TOKEN" "$API/getAllRoles?type=globalRoles" | python3 -c "import json,sys; print(len(json.load(sys.stdin)))" 2>/dev/null || echo "?") +ITEM_COUNT=$(curl -sf -u "$JENKINS_USER:$JENKINS_TOKEN" "$API/getAllRoles?type=projectRoles" | python3 -c "import json,sys; print(len(json.load(sys.stdin)))" 2>/dev/null || echo "?") +AGENT_COUNT=$(curl -sf -u "$JENKINS_USER:$JENKINS_TOKEN" "$API/getAllRoles?type=slaveRoles" | python3 -c "import json,sys; print(len(json.load(sys.stdin)))" 2>/dev/null || echo "?") +GLOBAL_USERS=$(curl -sf -u "$JENKINS_USER:$JENKINS_TOKEN" "$API/getRoleAssignments?type=globalRoles" | python3 -c "import json,sys; print(len(json.load(sys.stdin)))" 2>/dev/null || echo "?") + +echo " Global roles: $GLOBAL_COUNT" +echo " Item roles: $ITEM_COUNT" +echo " Agent roles: $AGENT_COUNT" +echo " Global role assignments: $GLOBAL_USERS" +echo "" +echo "=== Done ===" diff --git a/src/main/java/com/michelin/cio/hudson/plugins/rolestrategy/RoleBasedAuthorizationStrategy.java b/src/main/java/com/michelin/cio/hudson/plugins/rolestrategy/RoleBasedAuthorizationStrategy.java index ff7f5e7a..d2906230 100644 --- a/src/main/java/com/michelin/cio/hudson/plugins/rolestrategy/RoleBasedAuthorizationStrategy.java +++ b/src/main/java/com/michelin/cio/hudson/plugins/rolestrategy/RoleBasedAuthorizationStrategy.java @@ -109,6 +109,7 @@ public class RoleBasedAuthorizationStrategy extends AuthorizationStrategy { public static final String GLOBAL = "globalRoles"; public static final String PROJECT = "projectRoles"; public static final String SLAVE = "slaveRoles"; + public static final String AGENT = "agentRoles"; public static final String PERMISSION_TEMPLATES = "permissionTemplates"; public static final String MACRO_ROLE = "roleMacros"; @@ -190,6 +191,7 @@ public RoleBasedAuthorizationStrategy(Map grantedRoles, @CheckF public static final Permission[] SYSTEM_READ_AND_ITEM_ROLES_ADMIN = new Permission[] { Jenkins.SYSTEM_READ, ITEM_ROLES_ADMIN }; + @SuppressFBWarnings(value = "MS_PKGPROTECT", justification = "Used by jelly pages") @Restricted(NoExternalUse.class) // called by jelly public static final Permission[] SYSTEM_READ_AND_SOME_ROLES_ADMIN = new Permission[] { Jenkins.SYSTEM_READ, ITEM_ROLES_ADMIN, AGENT_ROLES_ADMIN }; @@ -466,17 +468,11 @@ private static boolean hasPermissionByRoleTypeForUpdates(String roleTypeAsString private static void checkPermByRoleTypeForUpdates(@NonNull String roleType) { switch (roleType) { - case RoleBasedAuthorizationStrategy.GLOBAL: - checkAdminPerm(); - break; - case RoleBasedAuthorizationStrategy.PROJECT: - checkPerms(ITEM_ROLES_ADMIN); - break; - case RoleBasedAuthorizationStrategy.SLAVE: - checkPerms(AGENT_ROLES_ADMIN); - break; - default: - throw new IllegalArgumentException("Unknown RoleType: " + roleType); + case RoleBasedAuthorizationStrategy.GLOBAL -> checkAdminPerm(); + case RoleBasedAuthorizationStrategy.PROJECT -> checkPerms(ITEM_ROLES_ADMIN); + case RoleBasedAuthorizationStrategy.SLAVE, RoleBasedAuthorizationStrategy.AGENT -> + checkPerms(AGENT_ROLES_ADMIN); + default -> throw new IllegalArgumentException("Unknown RoleType: " + roleType); } } @@ -1042,6 +1038,205 @@ public void doGetAllRoles(@QueryParameter(fixEmpty = true) String type) throws I writer.close(); } + /** + * Paginated, merged assignment endpoint for the Role Assignments UI. + * Returns users/groups across all role types with their role assignments. + * + * @param start pagination start index (default 0) + * @param limit page size (default 100) + * @param query optional search filter on user/group name + * @param filterRole optional role filter in format "type:roleName" (can be repeated via comma) + */ + @GET + @Restricted(NoExternalUse.class) + public void doGetPaginatedAssignments( + @QueryParameter(fixEmpty = true) Integer start, + @QueryParameter(fixEmpty = true) Integer limit, + @QueryParameter(fixEmpty = true) String query, + @QueryParameter(fixEmpty = true) String filterRole, + @QueryParameter(fixEmpty = true) String includeSids) throws IOException { + + Jenkins.get().checkPermission(Jenkins.SYSTEM_READ); + if (start == null || start < 0) { + start = 0; + } + if (limit == null || limit < 1) { + limit = 100; + } + limit = Math.min(limit, 1000); + + // Parse role filters + Set roleFilters = new HashSet<>(); + if (filterRole != null) { + for (String f : filterRole.split(",")) { + if (!f.trim().isEmpty()) { + roleFilters.add(f.trim()); + } + } + } + + // Parse additional SIDs to include (from client display name cache matches) + Set additionalSidSet = new HashSet<>(); + if (includeSids != null) { + for (String s : includeSids.split(",")) { + if (!s.trim().isEmpty()) { + additionalSidSet.add(s.trim()); + } + } + } + + // Merge all types into a unified map: key="TYPE:sid" -> { name, type, roles: { globalRoles: [...], ... } } + Map merged = new TreeMap<>(); + + for (RoleType rt : new RoleType[]{RoleType.Global, RoleType.Project, RoleType.Slave}) { + String typeStr = rt.getStringType(); + try { + checkPermByRoleTypeForReading(typeStr); + } catch (Exception e) { + continue; + } + + Set sidEntries = getRoleMap(rt).getSidEntries(true); + SortedMap> rolesEntries = getGrantedRolesEntries(typeStr); + + // Ensure a user object exists for every SID in this scope + for (PermissionEntry entry : sidEntries) { + String key = entry.getType().toString() + ":" + entry.getSid(); + if (!merged.containsKey(key)) { + JSONObject userObj = new JSONObject(); + userObj.put("name", entry.getSid()); + userObj.put("type", entry.getType().toString()); + JSONObject rolesMap = new JSONObject(); + rolesMap.put(GLOBAL, new JSONArray()); + rolesMap.put(PROJECT, new JSONArray()); + rolesMap.put(SLAVE, new JSONArray()); + userObj.put("roles", rolesMap); + merged.put(key, userObj); + } + } + + // Invert: walk each role once and append its name to every assigned SID's list. + // Roles iterate in SortedMap order, preserving per-SID ordering. + for (Map.Entry> roleEntry : rolesEntries.entrySet()) { + String roleName = roleEntry.getKey().getName(); + for (PermissionEntry entry : roleEntry.getValue()) { + String key = entry.getType().toString() + ":" + entry.getSid(); + JSONObject userObj = merged.get(key); + if (userObj != null) { + userObj.getJSONObject("roles").getJSONArray(typeStr).add(roleName); + } + } + } + } + + // Ensure anonymous and authenticated exist + if (!merged.containsKey("USER:anonymous")) { + JSONObject anon = new JSONObject(); + anon.put("name", "anonymous"); + anon.put("type", "USER"); + JSONObject r = new JSONObject(); + r.put(GLOBAL, new JSONArray()); + r.put(PROJECT, new JSONArray()); + r.put(SLAVE, new JSONArray()); + anon.put("roles", r); + merged.put("USER:anonymous", anon); + } + if (!merged.containsKey("GROUP:authenticated")) { + JSONObject auth = new JSONObject(); + auth.put("name", "authenticated"); + auth.put("type", "GROUP"); + JSONObject r = new JSONObject(); + r.put(GLOBAL, new JSONArray()); + r.put(PROJECT, new JSONArray()); + r.put(SLAVE, new JSONArray()); + auth.put("roles", r); + merged.put("GROUP:authenticated", auth); + } + + // Sort: anonymous first, authenticated second, then alphabetical + List sortedKeys = new ArrayList<>(merged.keySet()); + sortedKeys.sort((a, b) -> { + if (a.equals("USER:anonymous")) { + return -1; + } + if (b.equals("USER:anonymous")) { + return 1; + } + if (a.equals("GROUP:authenticated")) { + return -1; + } + if (b.equals("GROUP:authenticated")) { + return 1; + } + return a.compareToIgnoreCase(b); + }); + + // Filter + List filtered = new ArrayList<>(); + String lowerQuery = query != null ? query.toLowerCase() : ""; + for (String key : sortedKeys) { + JSONObject user = merged.get(key); + // Text filter — matches SID, or is in the additionalSids list (display name matches from client cache) + if (!lowerQuery.isEmpty()) { + boolean textMatch = user.getString("name").toLowerCase().contains(lowerQuery); + if (!textMatch) { + textMatch = additionalSidSet.contains(key); + } + if (!textMatch) { + continue; + } + } + // Role filter + if (!roleFilters.isEmpty()) { + boolean matchesAny = false; + for (String rf : roleFilters) { + int colonIdx = rf.indexOf(':'); + if (colonIdx < 0) { + continue; + } + String rfType = rf.substring(0, colonIdx); + String rfRole = rf.substring(colonIdx + 1); + JSONArray userRoles = user.getJSONObject("roles").optJSONArray(rfType); + if (userRoles != null) { + for (Object r : userRoles) { + if (rfRole.equals(r.toString())) { + matchesAny = true; + break; + } + } + } + if (matchesAny) { + break; + } + } + if (!matchesAny) { + continue; + } + } + filtered.add(user); + } + + // Paginate + int total = filtered.size(); + int safeStart = Math.min(start, total); + int safeEnd = Math.min(safeStart + limit, total); + List page = filtered.subList(safeStart, safeEnd); + + // Response + JSONObject response = new JSONObject(); + response.put("total", total); + response.put("start", safeStart); + response.put("limit", page.size()); + JSONArray items = new JSONArray(); + items.addAll(page); + response.put("items", items); + + Stapler.getCurrentResponse2().setContentType("application/json;charset=UTF-8"); + Writer writer = Stapler.getCurrentResponse2().getWriter(); + response.write(writer); + writer.close(); + } + /** * API method to get all SIDs and the assigned roles for a roletype. * @@ -1412,100 +1607,6 @@ public FormValidation doCheckForWhitespace(@QueryParameter String value) { } } - /** - * Called on role management form's submission. - */ - @RequirePOST - @Restricted(NoExternalUse.class) - public void doRolesSubmit(StaplerRequest2 req, StaplerResponse2 rsp) throws ServletException, IOException { - checkPerms(ITEM_ROLES_ADMIN, AGENT_ROLES_ADMIN); - - req.setCharacterEncoding("UTF-8"); - JSONObject json = req.getSubmittedForm(); - AuthorizationStrategy strategy = this.newInstance(req, json); - instance().setAuthorizationStrategy(strategy); - // Persist the data - persistChanges(); - } - - /** - * Called on role assignment form's submission. - */ - @RequirePOST - @Restricted(NoExternalUse.class) - public void doAssignSubmit(JSONObject json) throws ServletException, IOException { - checkPerms(ITEM_ROLES_ADMIN, AGENT_ROLES_ADMIN); - - AuthorizationStrategy oldStrategy = instance().getAuthorizationStrategy(); - - if (oldStrategy instanceof RoleBasedAuthorizationStrategy strategy) { - Map maps = strategy.getRoleMaps(); - - for (Map.Entry map : maps.entrySet()) { - final String roleTypeAsString = map.getKey().getStringType(); - // if no permission, take the globalRoles from the oldStrategy - if (!hasPermissionByRoleTypeForUpdates(roleTypeAsString)) { - LOGGER.fine("Not enough permissions to save assignments for " + roleTypeAsString + ". Skipping..."); - continue; - } - LOGGER.fine("Saving assignments for " + roleTypeAsString); - - // Get roles and skip non-existent role entries (backward-comp) - RoleMap roleMap = map.getValue(); - JSONArray userEntries = json.getJSONArray(map.getKey().getStringType()); - - roleMap.clearSids(); - - userEntries.forEach(e -> { - JSONObject entry = (JSONObject) e; - PermissionEntry pe = new PermissionEntry(AuthorizationType.valueOf(entry.getString("type")), entry.getString("name")); - entry.getJSONArray("roles").forEach(r -> { - Role role = roleMap.getRole((String) r); - if (role != null) { - roleMap.assignRole(role, pe); - } - }); - }); - } - // Persist the data - persistChanges(); - } - } - - /** - * Called on role generator form submission. - */ - @RequirePOST - @Restricted(NoExternalUse.class) - public void doTemplatesSubmit(StaplerRequest2 req, StaplerResponse2 rsp) throws ServletException, IOException { - checkPermByRoleTypeForUpdates(PROJECT); - req.setCharacterEncoding("UTF-8"); - JSONObject json = req.getSubmittedForm(); - AuthorizationStrategy oldStrategy = instance().getAuthorizationStrategy(); - if (json.has(PERMISSION_TEMPLATES) && oldStrategy instanceof RoleBasedAuthorizationStrategy) { - RoleBasedAuthorizationStrategy strategy = (RoleBasedAuthorizationStrategy) oldStrategy; - - JSONObject permissionTemplatesJson = json.getJSONObject(PERMISSION_TEMPLATES); - Map permissionTemplates = new TreeMap<>(); - for (Map.Entry r : (Set>) - permissionTemplatesJson.getJSONObject("data").entrySet()) { - String templateName = r.getKey(); - Set permissionStrings = new HashSet<>(); - for (Map.Entry e : (Set>) r.getValue().entrySet()) { - if (e.getValue()) { - permissionStrings.add(e.getKey()); - } - } - PermissionTemplate permissionTemplate = new PermissionTemplate(templateName, permissionStrings); - permissionTemplates.put(templateName, permissionTemplate); - } - - strategy.permissionTemplates = permissionTemplates; - strategy.refreshPermissionsFromTemplate(); - persistChanges(); - } - } - /** * Method called on Jenkins Manage panel submission, and plugin specific forms to create the * {@link AuthorizationStrategy} object. @@ -1643,17 +1744,12 @@ public List getGroups(@NonNull String type) { List groups = new ArrayList<>(); PermissionScope permissionScope; switch (type) { - case GLOBAL: - permissionScope = PermissionScope.JENKINS; - break; - case PROJECT: - permissionScope = PermissionScope.ITEM_GROUP; - break; - case SLAVE: - permissionScope = PermissionScope.COMPUTER; - break; - default: + case GLOBAL -> permissionScope = PermissionScope.JENKINS; + case PROJECT -> permissionScope = PermissionScope.ITEM_GROUP; + case SLAVE, AGENT -> permissionScope = PermissionScope.COMPUTER; + default -> { return groups; + } } for (PermissionGroup group : PermissionGroup.getAll()) { if (group == PermissionGroup.get(Permission.class)) { @@ -1681,25 +1777,27 @@ public List getGroups(@NonNull String type) { */ @Restricted(NoExternalUse.class) public boolean showPermission(String type, Permission p) { - switch (type) { - case GLOBAL: + return switch (type) { + case GLOBAL -> { if (PermissionHelper.isDangerous(p)) { - return false; + yield false; } - return p.getEnabled(); - case PROJECT: + yield p.getEnabled(); + } + case PROJECT -> { if (!p.isContainedBy(PermissionScope.ITEM_GROUP)) { - return false; + yield false; } - return p.getEnabled(); - case SLAVE: + yield p.getEnabled(); + } + case SLAVE -> { if (!p.isContainedBy(PermissionScope.COMPUTER)) { - return false; + yield false; } - return p.getEnabled(); - default: - return false; - } + yield p.getEnabled(); + } + default -> false; + }; } /** diff --git a/src/main/java/com/michelin/cio/hudson/plugins/rolestrategy/RoleMap.java b/src/main/java/com/michelin/cio/hudson/plugins/rolestrategy/RoleMap.java index 250f27fe..1f2ac31f 100644 --- a/src/main/java/com/michelin/cio/hudson/plugins/rolestrategy/RoleMap.java +++ b/src/main/java/com/michelin/cio/hudson/plugins/rolestrategy/RoleMap.java @@ -597,6 +597,25 @@ public Set getSidEntriesForRole(String roleName) { return null; } + /** + * Checks if a user or group is assigned to the given role. + * + * @param role The role to check + * @param sid The user or group name + * @param type "USER", "GROUP", or "EITHER" (any value other than "GROUP" is treated as USER). + * Matches against the specific type and against legacy ambiguous (EITHER) entries. + * @return true if the sid is assigned to the role + */ + public boolean isAssigned(@NonNull Role role, @NonNull String sid, @NonNull String type) { + Set entries = this.grantedRoles.get(role); + if (entries == null) { + return false; + } + AuthorizationType authType = "GROUP".equals(type) ? AuthorizationType.GROUP : AuthorizationType.USER; + return entries.contains(new PermissionEntry(authType, sid)) + || entries.contains(new PermissionEntry(AuthorizationType.EITHER, sid)); + } + /** * Get all the sids assigned to the {@link Role} named after the {@code roleName} param. * All types are returned to keep the api as compatible as possible. diff --git a/src/main/java/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig.java b/src/main/java/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig.java index 02441f6b..a4eec966 100644 --- a/src/main/java/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig.java +++ b/src/main/java/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig.java @@ -34,9 +34,12 @@ import hudson.model.ManagementLink; import hudson.security.AuthorizationStrategy; import hudson.security.Permission; -import hudson.util.FormApply; import jakarta.servlet.ServletException; import java.io.IOException; +import java.util.HashSet; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; import jenkins.model.Jenkins; import jenkins.util.SystemProperties; import net.sf.json.JSONObject; @@ -77,7 +80,7 @@ public static int getMaxRows() { public String getIconFileName() { // Only show this link if the role-based authorization strategy has been enabled if (Jenkins.get().getAuthorizationStrategy() instanceof RoleBasedAuthorizationStrategy) { - return "symbol-lock-closed-outline plugin-ionicons-api"; + return "symbol-shield-outline plugin-ionicons-api"; } return null; } @@ -161,79 +164,529 @@ public AuthorizationStrategy getStrategy() { } /** - * Called on roles management form submission. + * Called when deleting a role via the UI. */ @RequirePOST @Restricted(NoExternalUse.class) - public void doRolesSubmit(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException, ServletException { + public void doDeleteRoleSubmit(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException, ServletException { Jenkins.get().checkAnyPermission(RoleBasedAuthorizationStrategy.ADMINISTER_AND_SOME_ROLES_ADMIN); - // Let the strategy descriptor handle the form - RoleBasedAuthorizationStrategy.DESCRIPTOR.doRolesSubmit(req, rsp); - // Redirect to the plugin index page - FormApply.success(".").generateResponse(req, rsp, this); + String redirectUrl = req.getContextPath() + "/manage/role-strategy/manage-roles"; + + JSONObject json = getSubmittedFormOrRedirect(req, rsp, redirectUrl); + if (json == null) { + return; + } + + String scope = json.optString("scope", "").trim(); + String roleName = json.optString("roleName", "").trim(); + if (scope.isEmpty() || roleName.isEmpty()) { + rsp.sendRedirect(redirectUrl); + return; + } + + if (!checkScopePermission(scope)) { + rsp.sendError(400, "Unknown scope: " + scope); + return; + } + AuthorizationStrategy strategy = Jenkins.get().getAuthorizationStrategy(); + if (strategy instanceof RoleBasedAuthorizationStrategy rbas) { + rbas.doRemoveRoles(scope, roleName); + } + + rsp.sendRedirect(redirectUrl); + } + + /** + * Called when removing all role assignments for a user/group. + */ + @RequirePOST + @Restricted(NoExternalUse.class) + public void doDeleteAssignSubmit(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException, ServletException { + Jenkins.get().checkAnyPermission(RoleBasedAuthorizationStrategy.ADMINISTER_AND_SOME_ROLES_ADMIN); + String redirectUrl = req.getContextPath() + "/manage/role-strategy/"; + + JSONObject json = getSubmittedFormOrRedirect(req, rsp, redirectUrl); + if (json == null) { + return; + } + + String name = json.optString("name", "").trim(); + String type = json.optString("type", "USER").trim(); + if (name.isEmpty()) { + rsp.sendRedirect(redirectUrl); + return; + } + + AuthorizationStrategy strategy = Jenkins.get().getAuthorizationStrategy(); + if (strategy instanceof RoleBasedAuthorizationStrategy rbas) { + String[] scopes = { + RoleBasedAuthorizationStrategy.GLOBAL, + RoleBasedAuthorizationStrategy.PROJECT, + RoleBasedAuthorizationStrategy.SLAVE + }; + for (String scope : scopes) { + if (hasScopePermission(scope)) { + switch (type) { + case "USER" -> rbas.doDeleteUser(scope, name); + case "GROUP" -> rbas.doDeleteGroup(scope, name); + case "EITHER" -> rbas.doDeleteSid(scope, name); + default -> { } + } + } + } + } + + rsp.sendRedirect(redirectUrl); + } + + @RequirePOST + @Restricted(NoExternalUse.class) + public void doAddTemplateSubmit(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException, ServletException { + handleTemplateSubmit(req, rsp, false); + } + + /** + * Called from the edit template dialog form submission. + */ + @RequirePOST + @Restricted(NoExternalUse.class) + public void doEditTemplateSubmit(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException, ServletException { + handleTemplateSubmit(req, rsp, true); + } + + private void handleTemplateSubmit(StaplerRequest2 req, StaplerResponse2 rsp, boolean overwrite) + throws IOException, ServletException { + Jenkins.get().checkPermission(RoleBasedAuthorizationStrategy.ITEM_ROLES_ADMIN); + String redirectUrl = req.getContextPath() + "/manage/role-strategy/permission-templates"; + + JSONObject json = getSubmittedFormOrRedirect(req, rsp, redirectUrl); + if (json == null) { + return; + } + + String nameField = overwrite ? "originalTemplateName" : "templateName"; + String templateName = json.optString(nameField, "").trim(); + if (templateName.isEmpty()) { + rsp.sendRedirect(redirectUrl); + return; + } + + String permIds = String.join(",", collectPermissionIds(json)); + AuthorizationStrategy strategy = Jenkins.get().getAuthorizationStrategy(); + if (strategy instanceof RoleBasedAuthorizationStrategy rbas) { + rbas.doAddTemplate(templateName, permIds, overwrite); + } + + if (!rsp.isCommitted()) { + rsp.sendRedirect(redirectUrl); + } } /** - * Called on roles generator form submission. + * Called when deleting a permission template. */ @RequirePOST @Restricted(NoExternalUse.class) - public void doTemplatesSubmit(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException, ServletException { + public void doDeleteTemplateSubmit(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException, ServletException { Jenkins.get().checkPermission(RoleBasedAuthorizationStrategy.ITEM_ROLES_ADMIN); - // Let the strategy descriptor handle the form - RoleBasedAuthorizationStrategy.DESCRIPTOR.doTemplatesSubmit(req, rsp); - // Redirect to the plugin index page - FormApply.success(".").generateResponse(req, rsp, this); + String redirectUrl = req.getContextPath() + "/manage/role-strategy/permission-templates"; + + JSONObject json = getSubmittedFormOrRedirect(req, rsp, redirectUrl); + if (json == null) { + return; + } + + String templateName = json.optString("templateName", "").trim(); + if (templateName.isEmpty()) { + rsp.sendRedirect(redirectUrl); + return; + } + + AuthorizationStrategy strategy = Jenkins.get().getAuthorizationStrategy(); + if (strategy instanceof RoleBasedAuthorizationStrategy rbas) { + rbas.doRemoveTemplates(templateName, true); + } + + rsp.sendRedirect(redirectUrl); } - // no configuration on this page for submission - // public void doMacrosSubmit(StaplerRequest req, StaplerResponse rsp) throws IOException, UnsupportedEncodingException, - // ServletException, FormException { - // Hudson.getInstance().checkPermission(Jenkins.ADMINISTER); - // - // // TODO: Macros Enable/Disable - // - // // Redirect to the plugin index page - // FormApply.success(".").generateResponse(req, rsp, this); - // } + /** + * Called from the assign role dialog form submission. + * Only adds newly checked roles; existing assignments not shown in the form are preserved. + */ + @RequirePOST + @Restricted(NoExternalUse.class) + public void doAssignRoleSubmit(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException, ServletException { + handleAssignSubmit(req, rsp, false); + } /** - * Called on role's assignment form submission. + * Called from the edit assignment dialog form submission. + * Syncs assignments to match the form state: adds checked roles and removes unchecked ones. */ @RequirePOST @Restricted(NoExternalUse.class) - public void doAssignSubmit(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException, ServletException { - Jenkins.get().checkAnyPermission(RoleBasedAuthorizationStrategy.ADMINISTER_AND_SOME_ROLES_ADMIN); - // Let the strategy descriptor handle the form + public void doEditAssignSubmit(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException, ServletException { + handleAssignSubmit(req, rsp, true); + } + + @SuppressWarnings("unchecked") + private void handleAssignSubmit(StaplerRequest2 req, StaplerResponse2 rsp, boolean removeUnchecked) + throws IOException, ServletException { + AssignFormData data = parseAssignForm(req, rsp); + if (data == null) { + return; + } + + AuthorizationStrategy strategy = Jenkins.get().getAuthorizationStrategy(); + if (strategy instanceof RoleBasedAuthorizationStrategy rbas) { + PermissionEntry entry = entryFor(data.type, data.name); + boolean isConversion = !data.originalType.equals(data.type); + PermissionEntry originalEntry = isConversion ? entryFor(data.originalType, data.name) : null; + + for (String assignType : (Set) data.roles.keySet()) { + if (!hasScopePermission(assignType)) { + continue; + } + RoleMap roleMap = rbas.getRoleMap(RoleType.fromString(assignType)); + JSONObject roleEntries = data.roles.getJSONObject(assignType); + for (String roleName : (Set) roleEntries.keySet()) { + Role role = roleMap.getRole(roleName); + if (role == null) { + continue; + } + if (isConversion) { + roleMap.deleteRoleSid(originalEntry, roleName); + } + boolean shouldBeAssigned = roleEntries.getBoolean(roleName); + boolean isCurrentlyAssigned = roleMap.isAssigned(role, data.name, data.type); + if (shouldBeAssigned && !isCurrentlyAssigned) { + roleMap.assignRole(role, entry); + } else if (removeUnchecked && !shouldBeAssigned && isCurrentlyAssigned) { + roleMap.deleteRoleSid(entry, roleName); + } + } + } + + saveJenkinsConfig(); + } + + rsp.sendRedirect(req.getContextPath() + "/manage/role-strategy/"); + } + + /** + * Called from the add role dialog form submission. + * Creates a new role; no-op if a role with the same name already exists. + */ + @RequirePOST + @Restricted(NoExternalUse.class) + public void doAddRoleSubmit(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException, ServletException { + handleRoleSubmit(req, rsp, false); + } + + /** + * Called from the edit role dialog form submission. + * Replaces an existing role's definition while preserving its sid assignments. + */ + @RequirePOST + @Restricted(NoExternalUse.class) + public void doEditRoleSubmit(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException, ServletException { + handleRoleSubmit(req, rsp, true); + } + + private void handleRoleSubmit(StaplerRequest2 req, StaplerResponse2 rsp, boolean edit) + throws IOException, ServletException { + RoleFormData data = parseRoleForm(req, rsp); + if (data == null) { + return; + } + + Pattern compiledPattern = compilePatternOrError(data.pattern, rsp); + if (compiledPattern == null) { + return; + } + + Set permissions = edit + ? collectPermissionsFromFlat(data.json) + : collectPermissionsFromScoped(data.json, data.scope); + String templateName = data.json.optString("templateName", ""); + String tmplName = templateName.isEmpty() ? null : templateName; + + AuthorizationStrategy strategy = Jenkins.get().getAuthorizationStrategy(); + if (strategy instanceof RoleBasedAuthorizationStrategy rbas) { + RoleMap roleMap = rbas.getRoleMap(RoleType.fromString(data.scope)); + Role newRole = new Role(data.roleName, compiledPattern, permissions, "", tmplName); + if (tmplName != null && RoleBasedAuthorizationStrategy.PROJECT.equals(data.scope)) { + newRole.refreshPermissionsFromTemplate(rbas.getPermissionTemplate(tmplName)); + } + if (edit) { + Role existingRole = roleMap.getRole(data.roleName); + if (existingRole != null) { + Set sids = roleMap.getGrantedRolesEntries().get(existingRole); + roleMap.removeRole(existingRole); + roleMap.addRole(newRole, sids != null ? sids : new HashSet<>()); + saveJenkinsConfig(); + } + } else { + roleMap.addRole(newRole); + saveJenkinsConfig(); + } + } + + rsp.sendRedirect(req.getContextPath() + "/manage/role-strategy/manage-roles"); + } + + // ============================================ + // Shared form-parsing helpers + // ============================================ + + /** + * Compile a regex pattern, sending a 400 error response if invalid. + * + * @return the compiled pattern, or null if an error response was sent + */ + @CheckForNull + private static Pattern compilePatternOrError(String pattern, StaplerResponse2 rsp) + throws IOException { + try { + return Pattern.compile(pattern); + } catch (PatternSyntaxException e) { + rsp.sendError(400, "Invalid pattern: " + e.getDescription()); + return null; + } + } + + /** + * Parse submitted form JSON, redirecting on failure. + * + * @return the parsed JSON, or null if redirect was sent + */ + @CheckForNull + private static JSONObject getSubmittedFormOrRedirect( + StaplerRequest2 req, StaplerResponse2 rsp, String redirectUrl) throws IOException { req.setCharacterEncoding("UTF-8"); - JSONObject json = req.getSubmittedForm(); - JSONObject rolesMapping; - if (json.has("submit")) { - String rm = json.getString("rolesMapping"); - rolesMapping = JSONObject.fromObject(rm); - } else { - rolesMapping = json.getJSONObject("rolesMapping"); + try { + return req.getSubmittedForm(); + } catch (Exception e) { + rsp.sendRedirect(redirectUrl); + return null; + } + } + + private static PermissionEntry entryFor(String type, String name) { + return switch (type) { + case "GROUP" -> PermissionEntry.group(name); + case "EITHER" -> new PermissionEntry(AuthorizationType.EITHER, name); + default -> PermissionEntry.user(name); + }; + } + + private static void saveJenkinsConfig() throws ServletException { + try { + Jenkins.get().save(); + } catch (Exception e) { + throw new ServletException(e); + } + } + + /** Parsed data from an assign/edit-assign dialog submission. */ + private record AssignFormData(String name, String type, String originalType, JSONObject roles) {} + + @CheckForNull + private static AssignFormData parseAssignForm(StaplerRequest2 req, StaplerResponse2 rsp) + throws IOException { + Jenkins.get().checkAnyPermission(RoleBasedAuthorizationStrategy.ADMINISTER_AND_SOME_ROLES_ADMIN); + String redirectUrl = req.getContextPath() + "/manage/role-strategy/"; + + JSONObject json = getSubmittedFormOrRedirect(req, rsp, redirectUrl); + if (json == null) { + return null; + } + + String name = json.optString("name", "").trim(); + String type = json.optString("type", "USER"); + String originalType = json.optString("originalType", type); + if (name.isEmpty()) { + rsp.sendRedirect(redirectUrl); + return null; + } + + JSONObject roles = json.optJSONObject("roles"); + if (roles == null) { + rsp.sendRedirect(redirectUrl); + return null; + } + + return new AssignFormData(name, type, originalType, roles); + } + + /** Parsed data from an add/edit role dialog submission. */ + private record RoleFormData(JSONObject json, String scope, String roleName, String pattern) {} + + @CheckForNull + private static RoleFormData parseRoleForm(StaplerRequest2 req, StaplerResponse2 rsp) + throws IOException { + Jenkins.get().checkAnyPermission(RoleBasedAuthorizationStrategy.ADMINISTER_AND_SOME_ROLES_ADMIN); + String redirectUrl = req.getContextPath() + "/manage/role-strategy/manage-roles"; + + JSONObject json = getSubmittedFormOrRedirect(req, rsp, redirectUrl); + if (json == null) { + return null; + } + + String scope = json.optString("scope", "globalRoles"); + + // Enforce per-scope permission + if (!checkScopePermission(scope)) { + rsp.sendError(400, "Unknown scope: " + scope); + return null; + } + + // Edit uses originalRoleName, add uses roleName + String roleName = json.optString("originalRoleName", "").trim(); + if (roleName.isEmpty()) { + roleName = json.optString("roleName", "").trim(); + } + String pattern = json.optString("pattern", ".*").trim(); + + if (roleName.isEmpty()) { + rsp.sendRedirect(redirectUrl); + return null; + } + + if ("globalRoles".equals(scope)) { + pattern = ".*"; + } else if (pattern.isEmpty()) { + rsp.sendRedirect(redirectUrl); + return null; + } + + return new RoleFormData(json, scope, roleName, pattern); + } + + /** + * Check that the current user has permission for the given role scope. + * Throws AccessDeniedException if not. + * Returns false if the scope is unknown (caller should send a 400 response). + */ + private static boolean checkScopePermission(String scope) { + switch (scope) { + case RoleBasedAuthorizationStrategy.GLOBAL -> + Jenkins.get().checkPermission(Jenkins.ADMINISTER); + case RoleBasedAuthorizationStrategy.PROJECT -> + Jenkins.get().checkPermission(RoleBasedAuthorizationStrategy.ITEM_ROLES_ADMIN); + case RoleBasedAuthorizationStrategy.SLAVE, RoleBasedAuthorizationStrategy.AGENT -> + Jenkins.get().checkPermission(RoleBasedAuthorizationStrategy.AGENT_ROLES_ADMIN); + default -> { + return false; + } } - if (rolesMapping.has("agentRoles")) { - rolesMapping.put(RoleBasedAuthorizationStrategy.SLAVE, rolesMapping.getJSONArray("agentRoles")); + return true; + } + + /** + * Check if the current user has permission for the given role scope. + */ + private static boolean hasScopePermission(String scope) { + return switch (scope) { + case RoleBasedAuthorizationStrategy.GLOBAL -> + Jenkins.get().hasPermission(Jenkins.ADMINISTER); + case RoleBasedAuthorizationStrategy.PROJECT -> + Jenkins.get().hasPermission(RoleBasedAuthorizationStrategy.ITEM_ROLES_ADMIN); + case RoleBasedAuthorizationStrategy.SLAVE, RoleBasedAuthorizationStrategy.AGENT -> + Jenkins.get().hasPermission(RoleBasedAuthorizationStrategy.AGENT_ROLES_ADMIN); + default -> false; + }; + } + + /** + * Collect permissions from a flat "permissions" JSON object (edit role dialog). + * Checkbox names use [${p.id}] form so keys are bracket-wrapped. + */ + private static Set collectPermissionsFromFlat(JSONObject json) { + Set permissions = new HashSet<>(); + JSONObject permsJson = json.optJSONObject("permissions"); + if (permsJson != null) { + for (String rawKey : permsJson.keySet()) { + if (permsJson.optBoolean(rawKey, false)) { + String permId = stripBrackets(rawKey); + Permission p = Permission.fromId(permId); + if (p != null) { + permissions.add(p); + } + } + } + } + return permissions; + } + + /** + * Collect permissions from a scope-nested "permissions" JSON object (add role dialog). + */ + private static Set collectPermissionsFromScoped(JSONObject json, String scope) { + Set permissions = new HashSet<>(); + JSONObject permissionsJson = json.optJSONObject("permissions"); + if (permissionsJson != null) { + JSONObject scopePerms = permissionsJson.optJSONObject(scope); + if (scopePerms != null) { + for (String rawKey : scopePerms.keySet()) { + if (scopePerms.optBoolean(rawKey, false)) { + String permId = stripBrackets(rawKey); + Permission p = Permission.fromId(permId); + if (p != null) { + permissions.add(p); + } + } + } + } } - RoleBasedAuthorizationStrategy.DESCRIPTOR.doAssignSubmit(rolesMapping); - FormApply.success(".").generateResponse(req, rsp, this); + return permissions; } + /** + * Collect permission ID strings from a flat "permissions" JSON (template dialogs). + */ + private static Set collectPermissionIds(JSONObject json) { + Set permIds = new HashSet<>(); + JSONObject permsJson = json.optJSONObject("permissions"); + if (permsJson != null) { + for (String rawKey : permsJson.keySet()) { + if (permsJson.optBoolean(rawKey, false)) { + String permId = stripBrackets(rawKey); + if (Permission.fromId(permId) != null) { + permIds.add(permId); + } + } + } + } + return permIds; + } + + private static String stripBrackets(String key) { + if (key.startsWith("[") && key.endsWith("]")) { + return key.substring(1, key.length() - 1); + } + return key; + } + + @SuppressWarnings("unused") // used by jelly public ExtensionList getRoleMacroExtensions() { return RoleMacroExtension.all(); } + @SuppressWarnings("unused") // used by jelly public final RoleType getGlobalRoleType() { return RoleType.Global; } + @SuppressWarnings("unused") // used by jelly public final RoleType getProjectRoleType() { return RoleType.Project; } + @SuppressWarnings("unused") // used by jelly public final RoleType getSlaveRoleType() { return RoleType.Slave; } + } diff --git a/src/main/java/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyRootAction.java b/src/main/java/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyRootAction.java index 82ceff17..bf8477fe 100644 --- a/src/main/java/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyRootAction.java +++ b/src/main/java/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyRootAction.java @@ -59,7 +59,7 @@ public String getIconFileName() { // Only show if user has role admin permissions but NOT system read if (hasRoleAdmin && !hasSystemRead) { - return "symbol-lock-closed-outline plugin-ionicons-api"; + return "symbol-shield-outline plugin-ionicons-api"; } return null; @@ -82,7 +82,7 @@ public String getUrlName() { * This allows the RootAction to serve at /role-strategy while reusing all the logic. */ public Object getTarget() { - Jenkins.get().checkAnyPermission(RoleBasedAuthorizationStrategy.ADMINISTER_AND_SOME_ROLES_ADMIN); + Jenkins.get().checkAnyPermission(RoleBasedAuthorizationStrategy.SYSTEM_READ_AND_SOME_ROLES_ADMIN); return RoleStrategyConfig.get(); } } diff --git a/src/main/java/com/synopsys/arc/jenkins/plugins/rolestrategy/RoleType.java b/src/main/java/com/synopsys/arc/jenkins/plugins/rolestrategy/RoleType.java index 003fdda9..4d0c529f 100644 --- a/src/main/java/com/synopsys/arc/jenkins/plugins/rolestrategy/RoleType.java +++ b/src/main/java/com/synopsys/arc/jenkins/plugins/rolestrategy/RoleType.java @@ -58,19 +58,12 @@ public static RoleType FromString(String roleName) { * @since 2.3.0 */ public static RoleType fromString(String roleName) { - if (roleName.equals(RoleBasedAuthorizationStrategy.GLOBAL)) { - return Global; - } - - if (roleName.equals(RoleBasedAuthorizationStrategy.PROJECT)) { - return Project; - } - - if (roleName.equals(RoleBasedAuthorizationStrategy.SLAVE)) { - return Slave; - } - - throw new java.lang.IllegalArgumentException("Unexpected roleName=" + roleName); + return switch (roleName) { + case RoleBasedAuthorizationStrategy.GLOBAL -> Global; + case RoleBasedAuthorizationStrategy.PROJECT -> Project; + case RoleBasedAuthorizationStrategy.SLAVE, RoleBasedAuthorizationStrategy.AGENT -> Slave; + default -> throw new IllegalArgumentException("Unexpected roleName=" + roleName); + }; } /** diff --git a/src/main/java/org/jenkinsci/plugins/rolestrategy/AmbiguousSidsAdminMonitor.java b/src/main/java/org/jenkinsci/plugins/rolestrategy/AmbiguousSidsAdminMonitor.java index 73635d29..bcaa47da 100644 --- a/src/main/java/org/jenkinsci/plugins/rolestrategy/AmbiguousSidsAdminMonitor.java +++ b/src/main/java/org/jenkinsci/plugins/rolestrategy/AmbiguousSidsAdminMonitor.java @@ -57,6 +57,11 @@ public boolean isActivated() { return !ambiguousEntries.isEmpty(); } + @Override + public boolean isSecurity() { + return true; + } + @Override public String getDisplayName() { return Messages.RoleBasedProjectNamingStrategy_Ambiguous(); diff --git a/src/main/java/org/jenkinsci/plugins/rolestrategy/NamingStrategyAdministrativeMonitor.java b/src/main/java/org/jenkinsci/plugins/rolestrategy/NamingStrategyAdministrativeMonitor.java index c42cbc08..810677dd 100644 --- a/src/main/java/org/jenkinsci/plugins/rolestrategy/NamingStrategyAdministrativeMonitor.java +++ b/src/main/java/org/jenkinsci/plugins/rolestrategy/NamingStrategyAdministrativeMonitor.java @@ -24,4 +24,9 @@ public boolean isActivated() { && !(jenkins.getProjectNamingStrategy() instanceof RoleBasedProjectNamingStrategy)); } + + @Override + public boolean isSecurity() { + return true; + } } diff --git a/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/Messages.properties b/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/Messages.properties index 612036d5..2f3f9e97 100644 --- a/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/Messages.properties +++ b/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/Messages.properties @@ -24,7 +24,7 @@ RoleBasedAuthorizationStrategy.DisplayName=Role-Based Strategy RoleBasedAuthorizationStrategy.Description=Handle permissions by creating roles and assigning them to users/groups RoleBasedAuthorizationStrategy.Manage=Manage Roles -RoleBasedAuthorizationStrategy.ManageAndAssign=Manage and Assign Roles +RoleBasedAuthorizationStrategy.ManageAndAssign=Role Management RoleBasedAuthorizationStrategy.Assign=Assign Roles RoleBasedAuthorizationStrategy.ListAvalMacro=List Available Macros RoleBasedAuthorizationStrategy.PermissionGroupTitle=Role Based Strategy diff --git a/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/add-role-dialog.jelly b/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/add-role-dialog.jelly new file mode 100644 index 00000000..275e9e43 --- /dev/null +++ b/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/add-role-dialog.jelly @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + View available macros + + + + + + +
+ +
+ + + + +
+
+ + + + +
+ + + + + +
+ + + + + + + + +
${g.title}
+
+ + +
+ + + + + + + + +
+
+
+
+
+
+
+
+
+
+ ${%No matching permissions} +
+
+
+ + + + +
+
+
diff --git a/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/add-role-dialog.properties b/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/add-role-dialog.properties new file mode 100644 index 00000000..9330238d --- /dev/null +++ b/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/add-role-dialog.properties @@ -0,0 +1,2 @@ +none=None (custom permissions) +implied=implied diff --git a/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/add-template-dialog.jelly b/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/add-template-dialog.jelly new file mode 100644 index 00000000..6f8bd350 --- /dev/null +++ b/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/add-template-dialog.jelly @@ -0,0 +1,54 @@ + + + + + + + + + + + + + +
+ + + + + + + + +
${g.title}
+
+ + +
+ + + + + + + + +
+
+
+
+
+
+
+ ${%No matching permissions} +
+
+
+ + + + +
+
+
diff --git a/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/add-template-dialog.properties b/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/add-template-dialog.properties new file mode 100644 index 00000000..1d1cb364 --- /dev/null +++ b/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/add-template-dialog.properties @@ -0,0 +1 @@ +implied=implied diff --git a/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/assign-agent-roles.jelly b/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/assign-agent-roles.jelly deleted file mode 100644 index c5a5fd44..00000000 --- a/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/assign-agent-roles.jelly +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/assign-global-roles.jelly b/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/assign-global-roles.jelly deleted file mode 100644 index 453c6751..00000000 --- a/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/assign-global-roles.jelly +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - - - - - -
- - - -
-
- - - -
-
- - - - - - - - -
-
diff --git a/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/assign-project-roles.jelly b/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/assign-project-roles.jelly deleted file mode 100644 index a98fb323..00000000 --- a/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/assign-project-roles.jelly +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - - - - - -
- - - -
-
- - - -
-
- - - - - - - - -
- -
diff --git a/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/assign-role-dialog.jelly b/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/assign-role-dialog.jelly new file mode 100644 index 00000000..5b855bd7 --- /dev/null +++ b/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/assign-role-dialog.jelly @@ -0,0 +1,106 @@ + + + + + + + + + + + + +
+ + + "${r.pattern.toString()}" + + + + rsp-perm-info + + ${%Permissions} +
    + +
  • ${p.group.title}/${p.name}
  • +
    +
+
+ +
+
+
+
+
+ + + + + + + + + + +
+ + +
+
+ + + + + +
+ + + + +
${%Global roles}
+
+ + + +
+
+
+ + + + +
${%Item roles}
+
+ + + +
+
+
+ + + + +
${%Agent roles}
+
+ + + +
+
+
+ +
+ ${%No matching roles} +
+
+ +
+ + + + +
+
+
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 deleted file mode 100644 index 31cb3b83..00000000 --- a/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/assign-roles.jelly +++ /dev/null @@ -1,241 +0,0 @@ - - - - - - - - - + + - - ${g.title} - - - - - - - - - - - - -
- -
- -
- ${title} -
- - - - - - - - - -
- - -
"${attrs.role.pattern.toString()}"
- -
-
+ - - - -
- ${attrs.templateName} - -
- -
-
+ +
+
+ +
+ + +
+
+
- - - - - - - - - - - - - -
- -
- -
-
-
- +
+
${%Loading assignments}
+
- -

- ${it.manageRolesName} -

- + + + ${%Permission Templates} + + + + + + -
- -
-
+ + + + + + +
diff --git a/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/permission-templates.properties b/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/permission-templates.properties index be7f6f1d..6cf2bd40 100644 --- a/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/permission-templates.properties +++ b/src/main/resources/com/michelin/cio/hudson/plugins/rolestrategy/RoleStrategyConfig/permission-templates.properties @@ -1,2 +1,3 @@ blurb=Templates allow to simplify role management when many item roles with identical permissions are required. \ - Changing the template will automatically change the permissions of all roles that are using the template. + Changing the template will automatically change the permissions of all roles that are using the template. +implied=implied 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/resources/images/symbols/braces.svg b/src/main/resources/images/symbols/braces.svg deleted file mode 100644 index 5238c383..00000000 --- a/src/main/resources/images/symbols/braces.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/src/main/resources/lib/rolestrategy/dropdownList.jelly b/src/main/resources/lib/rolestrategy/dropdownList.jelly deleted file mode 100644 index fd91765f..00000000 --- a/src/main/resources/lib/rolestrategy/dropdownList.jelly +++ /dev/null @@ -1,76 +0,0 @@ - - - - - Foldable block expanded when the corresponding item is selected in the drop-down list. - - - ]]> - - - name of the drop-down list. - - - ]]> - - - id of the select - - - -
-
- ${attrs.title} - -
- - - -
- - -
-
- - - - -
diff --git a/src/main/resources/lib/rolestrategy/taglib b/src/main/resources/lib/rolestrategy/taglib deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/resources/org/jenkinsci/plugins/rolestrategy/AmbiguousSidsAdminMonitor/message.jelly b/src/main/resources/org/jenkinsci/plugins/rolestrategy/AmbiguousSidsAdminMonitor/message.jelly index 91171441..5bc21f7e 100644 --- a/src/main/resources/org/jenkinsci/plugins/rolestrategy/AmbiguousSidsAdminMonitor/message.jelly +++ b/src/main/resources/org/jenkinsci/plugins/rolestrategy/AmbiguousSidsAdminMonitor/message.jelly @@ -1,17 +1,15 @@ -
-
-
- - -
-
-
+ + -
There are several permissions declared in Role Based Strategy plugin configuration, that are ambiguous. Classify them correctly with 'USER:username' or 'GROUP:groupname'.
+
+ +
There are several permissions declared in Role Based Strategy plugin configuration that are ambiguous. Update + the permission assignments to user or group. +
    @@ -20,6 +18,5 @@
- -
+ \ No newline at end of file diff --git a/src/main/resources/org/jenkinsci/plugins/rolestrategy/NamingStrategyAdministrativeMonitor/message.jelly b/src/main/resources/org/jenkinsci/plugins/rolestrategy/NamingStrategyAdministrativeMonitor/message.jelly index 0cff96b8..d2c1e785 100644 --- a/src/main/resources/org/jenkinsci/plugins/rolestrategy/NamingStrategyAdministrativeMonitor/message.jelly +++ b/src/main/resources/org/jenkinsci/plugins/rolestrategy/NamingStrategyAdministrativeMonitor/message.jelly @@ -1,11 +1,7 @@ -
-
- - - + ${%NamingStrategyWarning} -
+
diff --git a/src/main/webapp/css/role-strategy.css b/src/main/webapp/css/role-strategy.css index 20d64607..ed2d8fd6 100644 --- a/src/main/webapp/css/role-strategy.css +++ b/src/main/webapp/css/role-strategy.css @@ -23,183 +23,835 @@ * THE SOFTWARE. */ -.role-strategy-table { - border-spacing: 2px; +/* Ensure content fits within the one-column layout */ +/* TODO remove after new settings layout is enabled by default */ +#main-panel { + max-width: 100%; } -.role-strategy-table tbody>tr>td { - height: 30px; +/* ============================================ + Container + ============================================ */ + +.rsp-container { + margin-bottom: var(--section-padding); } -.role-strategy-table .rsp-table--header-th { - text-align: center!important; +/* ============================================ + App Bar (search + filter + actions) + ============================================ */ + +.rsp-search-wrapper { + position: relative; } -.role-strategy-table thead .rsp-table--header-th.first { - border-top-left-radius: var(--table-border-radius); +.rsp-search-wrapper .jenkins-search__input { + padding-right: 2.25rem; } -.role-strategy-table thead .rsp-table--header-th.last { - border-top-right-radius: var(--table-border-radius); +/* ============================================ + Permission Filter Dropdown + ============================================ */ + +.rsp-filter { + position: absolute; + right: 0.375rem; + top: 50%; + transform: translateY(-50%); + z-index: 1; } -.role-strategy-table tfoot .rsp-table--header-th.first { - border-bottom-left-radius: var(--table-border-radius); +.rsp-filter__button { + padding: 0.25rem !important; + min-width: 1.875rem !important; + min-height: 1.875rem; + border-radius: 0.4375rem; + margin-right: -0.125rem; + color: var(--text-color-secondary); + transition: color var(--standard-transition); } -.role-strategy-table tfoot .rsp-table--header-th.last { - border-bottom-right-radius: var(--table-border-radius); +.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; + + /* Copied from core */ + background: color-mix(in sRGB, var(--card-background) 85%, transparent); + backdrop-filter: var(--dropdown-backdrop-filter); + box-shadow: var(--dropdown-box-shadow); + border-radius: 1rem; } -.rsp-table__header-column { - vertical-align: bottom!important; - text-align: left!important; +.rsp-filter__dropdown[hidden] { + display: none; } -.rsp-table__footer-column { - vertical-align: top!important; - text-align: left!important; +.rsp-filter__header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem; + flex-shrink: 0; } -.role-strategy-table th, .role-strategy-table td -{ - padding: 0 0.25rem!important; - vertical-align: middle; - text-align: center; +.rsp-filter__header-label { + font-weight: var(--font-bold-weight); + font-size: var(--font-size-sm); +} + +.rsp-filter__reset-button { + font-size: 0.8125rem; + color: var(--accent-color); + background: none; + border: none; + cursor: pointer; + padding: 0; +} + +.rsp-filter__search { + padding: 0 0.5rem 0.5rem 0.5rem; + border-bottom: var(--jenkins-border); +} + +.rsp-filter__search .jenkins-search { + width: 100%; +} + +.rsp-filter__list { + display: flex; + flex-direction: column; + padding: 0.375rem; + overflow-y: auto; +} + +.rsp-filter__group-title { + color: var(--text-color-secondary) !important; + margin: 0.375rem; + font-size: 0.8125rem; + font-weight: var(--font-bold-weight); + opacity: 0.8; +} + +.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; } -.role-strategy-table .caption-row TH { - padding: 0.25rem!important; - width: 1rem; +.rsp-filter__no-results { + padding: 1rem 0.75rem; + color: var(--text-color-secondary); + font-size: 0.875rem; text-align: center; + font-style: italic; } -.role-strategy-table tfoot th { - font-size: .875rem; - font-weight: 500; +.rsp-filter__no-results[hidden] { + display: none; } -.role-strategy-table .rsp-table__permission, -.role-strategy-table .rsp-table--header-th -{ - background: var(--table-body-background); +/* ============================================ + Cards + ============================================ */ + +.rsp-cards { + display: flex; + flex-direction: column; } -.role-strategy-table .caption-row TH span, -.role-strategy-table .rsp-table--vertical span, -.role-strategy-table .group-row TD span -{ - writing-mode: vertical-rl; - padding: 5px 0; +.rsp-card { + border: var(--jenkins-border); + border-radius: var(--form-input-border-radius); + background: var(--card-background); + overflow: hidden; } -.role-strategy-table TD.stop, -.role-strategy-table TD.start { - white-space: nowrap; - text-align: left; - width: 16px; +/* Connected borders managed via JS classes */ +.rsp-card.rsp-card--connected-top { + border-top-left-radius: 0; + border-top-right-radius: 0; + border-top: none; } -.role-strategy-table TD.left-most { - text-align: left; - white-space: nowrap; +.rsp-card.rsp-card--connected-bottom { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +/* Connected borders — pure CSS, handles both static and filtered cases. + JS classes (rsp-card--connected-top/bottom) provide overrides for + cases where hidden cards break adjacency in the DOM. */ + +/* Any card preceded by a visible card: flat top, no border */ +.rsp-card:not(.rsp-card--hidden) + .rsp-card:not(.rsp-card--hidden) { + border-top-left-radius: 0; + border-top-right-radius: 0; + border-top: none; } -.role-strategy-table div.pattern-cell { +/* Any card followed by a visible card: flat bottom */ +.rsp-card:not(.rsp-card--hidden):has(+ .rsp-card:not(.rsp-card--hidden)) { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +/* JS overrides for filtered views where hidden cards break adjacency */ +.rsp-card.rsp-card--connected-top { + border-top-left-radius: 0 !important; + border-top-right-radius: 0 !important; + border-top: none !important; +} + +.rsp-card.rsp-card--connected-bottom { + border-bottom-left-radius: 0 !important; + border-bottom-right-radius: 0 !important; +} + +.rsp-card--hidden { + display: none; +} + +/* ============================================ + Card Header + ============================================ */ + +.rsp-card__header { display: flex; - justify-content: left; + 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); } -.role-strategy-table div.pattern-cell svg { - margin-right: 3px; +.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; } -.role-strategy-table div.pattern-cell span { +.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; } -label.attach-previous { - margin-left: 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; } -.highlighted, -.highlighted td { - background-color: #FFF9C9!important; - color: var(--black)!important; +.rsp-card__summary--empty { + font-style: italic; + opacity: 0.7; } -.jenkins-checkbox { - vertical-align: middle; - margin: 3px 0px; +.rsp-card__actions { + display: flex; + align-items: center; + gap: 0.125rem; + flex-shrink: 0; } -@keyframes highlightentry { - from { background: #C4C080; } - to { background: transparent; } +.rsp-card__action { + padding: 0 !important; + min-height: 2rem; + width: 2rem; } -.highlight-entry { - -webkit-animation: highlightentry 5s; - -moz-animation: highlightentry 5s; - animation: highlightentry 5s; +.rsp-card__toggle { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + transition: transform var(--standard-transition); } -.jenkins-alert { - margin-top: 10px; +.rsp-card[aria-expanded="true"] .rsp-card__toggle { + transform: rotate(180deg); } -.rsp-entry-not-found { - text-decoration: line-through; - color: grey; +/* ============================================ + Card Body (collapsible) + ============================================ */ + +.rsp-card__body { + border-top: var(--jenkins-border); } -.rsp-table__icon-alert { - color: orange; +.rsp-card__body--collapsed { + height: 0; + overflow: hidden; + padding: 0; + border-top: none; + visibility: hidden; } -.rsp-table__cell { +/* ============================================ + Permissions Section + ============================================ */ + +.rsp-perm__group { display: flex; - align-items: center; - gap: 3px; + flex-direction: column; + border: none; + padding: 0; + margin: 0; } -.rsp-remove, .migrate { +.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; - height: 16px; + 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, +.rsp-perm-info { + color: var(--text-color-secondary); + cursor: help; + display: inline-flex; + align-items: center; + margin-left: 0.35rem; + opacity: 0.65; +} + +.rsp-perm__item-info svg, +.rsp-perm-info svg { + width: 14px; + height: 14px; +} + +.rsp-perm-info:hover { + opacity: 1; +} + +.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); } -button.migrate { +.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; - color: inherit; + background: none; + cursor: pointer; + color: var(--text-color-secondary); + border-radius: 50%; padding: 0; + transition: background var(--standard-transition); } -.patternAnchor { +.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; - color: var(--link-color); - font-weight: var(--link-font-weight); + 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; +} + +/* ============================================ + Cards with no permissions — non-expandable + ============================================ */ + +.rsp-card:has(.rsp-card__summary--empty) .rsp-card__header { + cursor: default; +} + +.rsp-card:has(.rsp-card__summary--empty) .rsp-card__header:hover { + background: none; +} + +.rsp-card:has(.rsp-card__summary--empty) .rsp-card__toggle { + visibility: hidden; +} + +/* ============================================ + Card body — view-only permissions (editing via dialog) + ============================================ */ + +.rsp-card__body .rsp-perm__item { + pointer-events: none; + cursor: default; +} + +/* ============================================ + 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; +} + +/* ============================================ + Collapsible Sections + ============================================ */ + +.rsp-section-collapsible .jenkins-section__title { + cursor: pointer; + user-select: none; } -.patternAnchor:hover { - text-decoration: var(--link-text-decoration--hover); +.rsp-section-collapsible .jenkins-section__title .rsp-section-chevron { + display: inline-block; + width: 1em; + height: 1em; + vertical-align: text-top; + margin-left: 0.25rem; + transition: transform 0.2s; + color: var(--text-color-secondary); } -.row-filter, .user-filter, .role-filter { +.rsp-section-collapsible.rsp-section--collapsed .rsp-section-chevron { + transform: rotate(-90deg); + vertical-align: text-bottom; +} + +.rsp-section-collapsible.rsp-section--collapsed .rsp-container, +.rsp-section-collapsible.rsp-section--collapsed > .jenkins-form-item { display: none; - max-width: 500px; } +/* ============================================ + Dirty indicator + ============================================ */ -.rsp-navigation__entries { +#rs-dirty-indicator { + display: none; +} + +/* ============================================ + Add Role Dialog + ============================================ */ + +.rsp-dialog-content { display: flex; - gap: 1rem; - margin-top: -15px; + flex-direction: column; } -.rsp-navigation__entries .jenkins-select { - max-width: 5rem; -} \ No newline at end of file +.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; +} + +/* ============================================ + Implied permissions in dialogs + ============================================ */ + +.rsp-implied-label { + color: var(--text-color-secondary); + font-style: italic; + font-size: 0.8125rem; +} + +.rsp-assign-dialog__role-item[data-implied="true"] { + opacity: 0.6; +} + +/* ============================================ + Assign Role Dialog + ============================================ */ + +.rsp-assign-dialog__roles { + border: var(--jenkins-border); + border-radius: var(--form-input-border-radius); + padding: 0 0 0.5rem 0; + margin-top: 0.5rem; +} + +.rsp-assign-dialog__group-title { + font-weight: var(--font-bold-weight); + font-size: var(--font-size-sm); + color: var(--text-color-secondary); + padding: 0.75rem 0.75rem 0.25rem; +} + +.rsp-assign-dialog__group { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.rsp-assign-dialog__role-item { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0 0.75rem; +} + +.rsp-assign-dialog__role-pattern { + font-size: 0.8125rem; + color: var(--text-color-secondary); +} + +.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(--background); + opacity: 0.8; +} + +/* ============================================ + Validation states + ============================================ */ + +.rsp-card--not-found .rsp-card__name { + text-decoration: line-through; + color: var(--error-color); +} + +.rsp-card--warning, +.rsp-card--ambiguous { + border-left: 3px solid var(--warning); +} + +.rsp-card--ambiguous .rsp-assign__chip-icon { + color: var(--warning); +} + +.rsp-ambiguity-warning { + margin-bottom: 1rem; +} + +/* ============================================ + Read-only Mode + ============================================ */ + +.rsp-card.rsp-card--read-only .rsp-card__actions .rsp-card__action, +.rsp-card.rsp-card--read-only .rsp-user-delete { + display: none; +} + +.rsp-card.rsp-card--read-only .rsp-perm__item { + pointer-events: none; + cursor: default; +} + +.rsp-table__icon { + cursor: pointer; +} diff --git a/src/main/webapp/js/table.js b/src/main/webapp/js/table.js index 392efd61..ee028ddb 100644 --- a/src/main/webapp/js/table.js +++ b/src/main/webapp/js/table.js @@ -1,143 +1,93 @@ -/* - * 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. + */ + +// Update connected card border classes based on visible cards +window.rspUpdateCardBorders = function (container) { + if (!container) container = document; + container.querySelectorAll(".rsp-cards").forEach((cards) => { + const visible = cards.querySelectorAll(".rsp-card:not(.rsp-card--hidden)"); + // Reset all + cards.querySelectorAll(".rsp-card").forEach((c) => { + c.classList.remove( + "rsp-card--connected-top", + "rsp-card--connected-bottom", + ); + }); + visible.forEach((card, i) => { + if (i > 0) card.classList.add("rsp-card--connected-top"); + if (i < visible.length - 1) + card.classList.add("rsp-card--connected-bottom"); + }); + }); +}; + +window.toQueryString = function (params) { + return "?" + new URLSearchParams(params).toString(); +}; + +var escapeHTML = function (unsafe) { + return unsafe.replace(/[&<>"']/g, function(m) { + switch (m) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + default: + return "'"; + } + }); +}; + +// Reconfigure Tippy tooltips that live inside elements so they +// (a) attach to the dialog (HTML5 top-layer) instead of , and +// (b) use position:fixed so they're not clipped by the dialog's overflow. +// Core's tooltip registrar runs at Behaviour priority 1000; we run after. +(function () { + if (typeof Behaviour === "undefined") return; + const reconfigure = (element) => { + const dialog = element.closest("dialog"); + if (!dialog || !element._tippy) return false; + if (element._tippy.props.appendTo === dialog) return true; + element._tippy.setProps({ + appendTo: dialog, + popperOptions: { strategy: "fixed" }, + }); + return true; + }; + Behaviour.specify( + ".rsp-perm-info[data-html-tooltip]", + "rsp-dialog-tooltip-fix", + 1001, + (element) => { + if (!reconfigure(element)) { + // Tippy may not be initialised on this pass; retry once on next frame. + requestAnimationFrame(() => reconfigure(element)); + } + }, + ); +})(); diff --git a/src/main/webapp/js/tableAssign.js b/src/main/webapp/js/tableAssign.js deleted file mode 100644 index 28c8ad49..00000000 --- a/src/main/webapp/js/tableAssign.js +++ /dev/null @@ -1,625 +0,0 @@ -/* - * The MIT License - * - * Copyright (c) 2022 - 2025, Markus Winter - * - * 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. - */ -// number of lines required for the user filter to get enabled -var filterLimit = 10; -// number of lines required for the footer to get displayed -var footerLimit = 20; -var globalTableHighlighter; -var newGlobalRowTemplate; -var itemTableHighlighter; -var newItemRowTemplate; -var agentTableHighlighter; -var newAgentRowTemplate; -let maxRows; - -const roleStrategyEntries = {}; - -function filterUsers(filter, tableId, page) { - const json = roleStrategyEntries[tableId]; - const table= document.getElementById(tableId); - const filtered = filter != null ? json.filter((entry) => entry["name"].toUpperCase().indexOf(filter) > -1) : json; - showEntries(tableId, filtered, page); - const container = table.closest(".rsp-roles-container"); - const roleInputFilter = container.querySelector(".role-input-filter"); - const roleFilter = roleInputFilter != null ? roleInputFilter.value.toUpperCase() : ""; - filterRoles(roleFilter, table); -} - -function filterRoles(filter, table) { - const rowCount = table.rows.length; - const startColumn = 2; // column 0 is the delete button, column 1 contains the user/group - const headerRow = table.rows[0]; - const endColumn = headerRow.cells.length; // last column is the delete button - for (let c = 0; c < endColumn; c++) { - let shouldFilter = true; - if (filter==null||!headerRow.cells[c].classList.contains("rsp-table--header-th") || headerRow.cells[c].textContent.toUpperCase().indexOf(filter) > -1) { - shouldFilter = false; - } - for (let r = 0; r < rowCount; r++) { - if (shouldFilter) { - table.rows[r].cells[c].style.display = "none"; - } else { - table.rows[r].cells[c].style.display = ""; - } - } - } -} - -Behaviour.specify(".user-input-filter", "RoleBasedAuthorizationStrategy", 0, function(e) { - e.onkeyup = debounce((event) => { - if (ignoreKeys(event.code)) { - return; - } - const filter = e.value.toUpperCase(); - const tableId = e.getAttribute("data-table-id"); - filterUsers(filter, tableId, 0); - }); -}); - - -Behaviour.specify(".role-input-filter", "RoleBasedAuthorizationStrategy", 0, function(e) { - e.onkeyup = debounce((event) => { - if (ignoreKeys(event.code)) { - return; - } - const filter = e.value.toUpperCase(); - const table = document.getElementById(e.getAttribute("data-table-id")); - filterRoles(filter, table); - }); -}); - -Behaviour.specify( - ".role-strategy-add-button", "RoleBasedAuthorizationStrategy", 0, function(button) { - button.onclick = function(e) { - const container = button.closest(".rsp-roles-container"); - const table = container.querySelector("table"); - const tableId = table.id; - const templateId = container.dataset.template; - const template = document.getElementById(templateId).content.firstElementChild; - const highlighter = container.dataset.highlighter; - addButtonAction(button, template, table, highlighter); - const tbody = table.tBodies[0]; - if (tbody.children.length >= filterLimit) { - const userfilters = document.querySelectorAll(".user-filter") - for (let q=0;q= footerLimit) { - table.tFoot.style.display = "table-footer-group"; - } - } - } -); - -function insertRow(template, tbody, tableHighlighter, name, type, roles, title, icon) { - const copy = template.cloneNode(true); - const removeDeleteButton = title != null; - const children = copy.childNodes; - let tooltipDescription = "Group"; - if (type==="USER") { - tooltipDescription = "User"; - } - children.forEach(function(item){ - item.outerHTML= item.outerHTML.replace(/{{USER}}/g, doubleEscapeHTML(name)).replace(/{{USERGROUP}}/g, tooltipDescription); - }); - - if (removeDeleteButton) { - copy.classList.remove("permission-row"); - } - copy.dataset.name = name; - copy.dataset.type = type; - children.forEach(function(item) { - if (removeDeleteButton) { - const removeButtons = item.querySelectorAll(".rsp-remove"); - removeButtons.forEach((r) => { - r.remove(); - }); - } - const roleName = item.dataset.roleName; - if (roles !== null && roleName !== null && roles.indexOf(roleName) != -1) { - const input = item.querySelector("input"); - input.checked = true; - } - }); - - const nameCell = copy.querySelector(".left-most"); - const nameDiv = document.createElement("div"); - nameDiv.classList.add("rsp-table__cell"); - if (icon != null) { - nameDiv.appendChild(generateSVGIcon(icon)); - } - if (type==="EITHER") { - const migrateButtons = copy.querySelectorAll(".migrate"); - migrateButtons.forEach((b) => { - b.classList.remove("jenkins-hidden"); - }); - } - if (title != null) { - nameDiv.append(title); - } else { - nameDiv.append(name); - } - nameCell.replaceChildren(nameDiv); - copy.setAttribute("name",'['+type+':'+name+']'); - tbody.appendChild(copy); - if (tableHighlighter !== null) { - highlighter = window[tableHighlighter]; - highlighter.scan(copy); - } -} - -function toggleRole(event) { - const cb = event.target; - const roleName = cb.closest("td").dataset.roleName; - const name = cb.closest("tr").dataset.name; - const type = cb.closest("tr").dataset.type - const tableId = cb.closest("table").id; - const json = roleStrategyEntries[tableId]; - const entry = findPermissionEntry(json, name, type); - const roles = entry["roles"]; - if (cb.checked) { - roles.push(roleName) - } else { - const index = roles.indexOf(roleName); - roles.splice(index, 1); - } -} - -Behaviour.specify(".rsp-checkbox", 'RoleBasedAuthorizationStrategy', 0, function(cb) { - cb.addEventListener("click", toggleRole); -}); - -function addButtonAction(button, template, table, tableHighlighter) { - const type = button.getAttribute('data-type'); - const tbody = table.tBodies[0]; - const json = roleStrategyEntries[table.id]; - - dialog.prompt(button.getAttribute('data-prompt')).then( (name) => { - name = name.trim(); - if (findPermissionEntry(json, name, type) != null) { - dialog.alert(button.getAttribute('data-error-message')) - return; - } - - insertRow(template, tbody, tableHighlighter, name, type, [], null, null) - addPermissionEntry(json, name, type); - const container = table.closest(".rsp-roles-container"); - const roleInputFilter = container.querySelector(".role-input-filter"); - let roleFilter = ""; - if (roleInputFilter !== null) { - roleFilter = roleInputFilter.value.toUpperCase(); - } - filterRoles(roleFilter, table); - Behaviour.applySubtree(table, true); - }); -} - - -Behaviour.specify(".role-strategy-table .rsp-remove", 'RoleBasedAuthorizationStrategy', 0, function(e) { - e.onclick = function() { - const table = this.closest("TABLE"); - const tableId = table.getAttribute("id"); - const tr = this.closest("TR"); - const parent = tr.parentNode; - const json = roleStrategyEntries[tableId]; - deletePermissionEntry(json, tr.dataset.name, tr.dataset.type); - parent.removeChild(tr); - if (parent.children.length < filterLimit) { - let userfilters = document.querySelectorAll(".user-filter") - for (let q=0;q= 0; i--) { - migrateButtons[i].remove(); - } - } else { - // there's already a row for the migrated name (unusual but OK), so merge them - - // migrate permissions from this row - const ambiguousPermissionInputs = tr.getElementsByTagName("INPUT"); - const unambiguousPermissionInputs = newNameElement.getElementsByTagName("INPUT"); - for (let i = 0; i < ambiguousPermissionInputs.length; i++){ - if (ambiguousPermissionInputs[i].type == "checkbox") { - unambiguousPermissionInputs[i].checked |= ambiguousPermissionInputs[i].checked; - } - newNameElement.classList.add('highlight-entry'); - } - - // remove this row - tr.parentNode.removeChild(tr); - } - Behaviour.applySubtree(table, true); - - let hasAmbiguousRows = false; - - for (let i = 0; i < tableRows.length; i++) { - if (tableRows[i].getAttribute('name') !== null && tableRows[i].getAttribute('name').startsWith('[EITHER')) { - hasAmbiguousRows = true; - } - } - if (!hasAmbiguousRows) { - const alertElements = document.getElementsByClassName("alert"); - for (let i = 0; i < alertElements.length; i++) { - if (alertElements[i].hasAttribute('data-table-id') && alertElements[i].getAttribute('data-table-id') === table.getAttribute('id')) { - alertElements[i].style.display = 'none'; // TODO animate this? - } - } - } - - return false; - }; - e = null; // avoid memory leak -}); - - -Behaviour.specify(".rsp-navigation__button-entry-down", "RoleBasedAuthorizationStrategy", 0, function(button) { - button.onclick = function() { - const container = button.closest(".rsp-roles-container"); - const table = container.querySelector("table"); - const tableId = table.id; - const navgiationDiv = button.closest(`.rsp-navigation__entries`); - const select = navgiationDiv.querySelector(".rsp-navigation__select"); - const page = parseInt(select.value) + 1; - const userInputFilter = container.querySelector(".user-input-filter"); - const userFilter = userInputFilter != null ? userInputFilter.value.toUpperCase() : ""; - filterUsers(userFilter, tableId, page); - } -}); - -Behaviour.specify(".rsp-navigation__button-entry-up", "RoleBasedAuthorizationStrategy", 0, function(button) { - button.onclick = function() { - const container = button.closest(".rsp-roles-container"); - const table = container.querySelector("table"); - const tableId = table.id; - const navgiationDiv = button.closest(`.rsp-navigation__entries`); - const select = navgiationDiv.querySelector(".rsp-navigation__select"); - const page = parseInt(select.value) - 1; - const userInputFilter = container.querySelector(".user-input-filter"); - const userFilter = userInputFilter != null ? userInputFilter.value.toUpperCase() : ""; - filterUsers(userFilter, tableId, page); - } -}); - -Behaviour.specify(".rsp-navigation__select", "RoleBasedAuthorizationStrategy", 0, function(select) { - select.onchange = function() { - const container = select.closest(".rsp-roles-container"); - const table = container.querySelector("table"); - const tableId = table.id; - const page = parseInt(select.value); - const userInputFilter = container.querySelector(".user-input-filter"); - const userFilter = userInputFilter != null ? userInputFilter.value.toUpperCase() : ""; - filterUsers(userFilter, tableId, page); - } -}); - -function deletePermissionEntry(json, name, type) { - let index = null; - for (const [i, line] of json.entries()) { - if (line["name"] === name && line["type"] === type) { - index = i; - } - } - if (index !== null) { - json.splice(index, 1); - } -} - -function addPermissionEntry(json, name, type) { - const entry = {}; - entry["name"] = name; - entry["type"] = type; - entry["roles"] = []; - json.unshift(entry); - return entry; -} - -function sortJson(json) { - json.sort(function(a, b) { - if (a["type"] === "USER" && a["name"] === "anonymous") { - return -1; - } - if ((a["type"] === "GROUP" && a["name"] === "authenticated") && - (b["type"] === "USER" && b["name"] === "anonymous")) { - return 1; - } - if ((a["type"] === "GROUP" && a["name"] == "authenticated") && - (b["type"] !== "USER" || b["name"] !== "anonymous")) { - return -1; - } - if (a["type"] === "GROUP" && a["name"] === "authenticated") { - return -1; - } - if (b["type"] === "USER" && b["name"] === "anonymous") { - return 1; - } - if ((b["type"] === "GROUP" && b["name"] === "authenticated") && - (a["type"] === "USER" && a["name"] === "anonymous")) { - return 1; - } - if ((b["type"] === "GROUP" && b["name"] == "authenticated") && - (a["type"] !== "USER" || a["name"] !== "anonymous")) { - return 1; - } - if (b["type"] === "GROUP" && b["name"] === "authenticated") { - return 1; - } - if (a["type"] === b["type"]) { - return a["name"] < b["name"] ? -1 : 1; - } - return a["type"] > b["type"] ? -1 : 1; - }); -} - -function findPermissionEntry(json, name, type, create = false) { - let entry = null; - for (const line of json) { - if (line["name"] === name && line["type"] === type) { - entry = line; - break; - } - } - if (entry == null && create) { - entry = addPermissionEntry(json, name, type); - } - return entry; -} - -// finds the roles for the given entry -// returns null when there is no entry found -function addFixedEntry(json, name, type, title, icon) { - const entry = findPermissionEntry(json, name, type, true); - entry["title"] = title; - entry["icon"] = icon; - return entry["roles"]; -} - -function generateSVGIcon(iconName) { - const icons = document.querySelector("#assign-roles-icons"); - - return icons.content.querySelector(`#${iconName}`).cloneNode(true); -} - -function updateEntryNavigation(tableId, count, current) { - const navgiationDiv = document.querySelector(`#${tableId}-container .rsp-navigation__entries`); - if (navgiationDiv === null) { - return; - } - const totalPages = Math.ceil(count / maxRows); - if (totalPages == 1) { - return - } - navgiationDiv.classList.toggle("jenkins-hidden", false); - const select = navgiationDiv.querySelector(".rsp-navigation__select"); - const upButton = navgiationDiv.querySelector(".rsp-navigation__button-entry-up"); - const downButton = navgiationDiv.querySelector(".rsp-navigation__button-entry-down"); - if (current + 1 === 1) { - upButton.disabled = true; - } else { - upButton.disabled = false; - } - if (current + 1 === totalPages) { - downButton.disabled = true; - } else { - downButton.disabled = false; - } - if (select.options.length != totalPages) { - if (totalPages > select.options.length) { - for (let i = select.options.length + 1; i <= totalPages; i++) { - const option = document.createElement("option"); - option.value = i - 1; - option.text = i; - select.add(option); - } - } - if (totalPages < select.options.length) { - for (let i = select.options.length - 1; i >= totalPages; i--) { - select.remove(i); - } - } - } - select.value = current; -} - -function showEntries(tableId, json, startPage) { - const dataHolder = document.getElementById("assign-roles"); - const container = document.getElementById(`${tableId}-container`); - const table = container.querySelector("table"); - const tableHighLighter = container.dataset.highlighter; - const tbody = document.createElement("tbody"); - const template = document.getElementById(container.dataset.template).content.firstElementChild; - - const start = startPage * maxRows; - const end = Math.min(start + maxRows, json.length); - for (let i = start; i < end; i++) { - const line = json[i]; - const name = line["name"]; - const type = line["type"]; - const roles = line["roles"]; - const title = line["title"]; - const icon = line["icon"]; - insertRow(template, tbody, tableHighLighter, name, type, roles, title, icon); - } - table.replaceChild(tbody, table.tBodies[0]); - Behaviour.applySubtree(table, true); - updateEntryNavigation(tableId, json.length, startPage); - if (tbody.children.length >= footerLimit) { - table.tFoot.classList.remove("jenkins-hidden"); - } -} - -function loadTable(tableId, param, globalVarName) { - const dataHolder = document.getElementById("assign-roles"); - const fetchUrl = dataHolder.dataset.fetchUrl; - - const params = new URLSearchParams({ type: param }); - fetch(fetchUrl + "?" + params) - .then((rsp) => rsp.json()) - .then((json) => { - roleStrategyEntries[tableId] = json; - addFixedEntry(json, "anonymous", "USER", dataHolder.dataset.textAnonymous, "rsp-person-icon"); - addFixedEntry(json, "authenticated", "GROUP", dataHolder.dataset.textAuthenticated, "rsp-people-icon"); - sortJson(json); - showEntries(tableId, json, 0); - }); -} - - -Behaviour.specify("#rsp-roles-save", "RoleBasedAuthorizationStrategy", 0, function(button) { - button.onclick = function(event) { - const form = document.getElementById("rsp-roles-form"); - const input = form.querySelector("input"); - input.value = JSON.stringify(roleStrategyEntries); - form.requestSubmit(); - } -}); - -Behaviour.specify("#rsp-roles-apply", "RoleBasedAuthorizationStrategy", 0, function(button) { - button.onclick = function(event) { - const form = document.getElementById("rsp-roles-form"); - const url = form.action; - const formData = new FormData(); - const rolesMapping = { - "rolesMapping": roleStrategyEntries - } - formData.append("json", JSON.stringify(rolesMapping)); - fetch(url, { - method: "POST", - headers: crumb.wrap({}), - body: formData - }).then((rsp => { - if (rsp.ok) { - notificationBar.show(button.dataset.message, notificationBar.SUCCESS) - } else { - notificationBar.error("Failed to apply changes.", notificationBar.ERROR) - } - })); - } -}); - - -document.addEventListener('DOMContentLoaded', function() { - - const dataHolder = document.getElementById("assign-roles"); - maxRows = parseInt(dataHolder.dataset.maxRows); - - // global roles initialization - const globalRoleInputFilter = document.getElementById('globalRoleInputFilter'); - if (globalRoleInputFilter) { - if (parseInt(globalRoleInputFilter.getAttribute("data-initial-size")) >= 10) { - globalRoleInputFilter.style.display = "block" - } - const globalUserInputFilter = document.getElementById('globalUserInputFilter'); - if (globalUserInputFilter && parseInt(globalUserInputFilter.getAttribute("data-initial-size")) >= 10) { - globalUserInputFilter.style.display = "block" - } - - globalTableHighlighter = new TableHighlighter('globalRoles', 0); - loadTable("globalRoles", "globalRoles"); - } - - // item roles initialization - const itemRoleInputFilter = document.getElementById('itemRoleInputFilter'); - if (itemRoleInputFilter) { - if (parseInt(itemRoleInputFilter.getAttribute("data-initial-size")) >= 10) { - itemRoleInputFilter.style.display = "block" - } - const itemUserInputFilter = document.getElementById('itemUserInputFilter'); - if (itemUserInputFilter && parseInt(itemUserInputFilter.getAttribute("data-initial-size")) >= 10) { - itemUserInputFilter.style.display = "block" - } - - itemTableHighlighter = new TableHighlighter('projectRoles', 0); - loadTable("projectRoles", "projectRoles"); - } - - // agent roles initialization - const agentRolesTable = document.getElementById('agentRoles'); - if (agentRolesTable) { - agentTableHighlighter = new TableHighlighter('agentRoles', 0); - loadTable("agentRoles", "slaveRoles"); - } -}); \ No newline at end of file diff --git a/src/main/webapp/js/tableManage.js b/src/main/webapp/js/tableManage.js index 06d88a3f..6d8fe368 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,1191 @@ * 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"; - } - } -} +// ============================================ +// Data +// ============================================ +// Assignment data: { globalRoles: [...], projectRoles: [...], slaveRoles: [...] } +const rspAssignmentData = {}; -getPattern = function(row) { - let pattern = ""; - patternEditInputs = row.getElementsByClassName("patternEdit"); - if (patternEditInputs.length > 0) { - pattern = patternEditInputs[0].value; - } - return pattern; -} +// Display name cache: { "USER:john": "John Doe", "GROUP:security-chapter": "Security Chapter" } +const rspDisplayNameCache = {}; + +// Role definitions loaded from embedded JSON +const rspRoleDefinitions = { + globalRoles: [], + projectRoles: [], + slaveRoles: [], +}; + +const rspTypeLabels = { + globalRoles: "Global", + projectRoles: "Item", + slaveRoles: "Agent", +}; +const rspAssignTypes = ["globalRoles", "projectRoles", "slaveRoles"]; -Behaviour.specify(".row-input-filter", "RoleBasedAuthorizationStrategy", 0, function(e) { - e.onkeyup = debounce((event) => { - if (ignoreKeys(event.code)) { - return; +// Merged user map: { "USER:alice": { name, type, roles: { globalRoles: [...], ... } } } +let rspMergedUsers = {}; + +// ============================================ +// Load data +// ============================================ + +const rspLoadRoleDefinitions = () => { + rspAssignTypes.forEach((type) => { + document.getElementById(`rsp-roles-${type.replace("Roles", "")}`); + // Map: globalRoles -> rsp-roles-global, projectRoles -> rsp-roles-project, slaveRoles -> rsp-roles-slave + const idMap = { + globalRoles: "rsp-roles-global", + projectRoles: "rsp-roles-project", + slaveRoles: "rsp-roles-slave", + }; + const scriptEl = document.getElementById(idMap[type]); + if (scriptEl) { + try { + rspRoleDefinitions[type] = JSON.parse(scriptEl.textContent); + } catch (e) {} } - let filter = e.value.toUpperCase(); - let table = document.getElementById(e.getAttribute("data-table-id")); - filterRows(filter, table); }); -}); - +}; -Behaviour.specify("svg.icon-pencil", 'RoleBasedAuthorizationStrategy', 0, function(e) { - e.onclick = handlePatternEdit; -}); +// Server-side paginated fetch +const rspFetchPage = (start, count, query, roleFilters) => { + const dataHolder = document.getElementById("role-strategy-data"); + if (!dataHolder) return Promise.resolve({ total: 0, items: [] }); -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(); - } else { - endPatternInput(span, false); - this.blur(); + const fetchUrl = dataHolder.dataset.fetchUrl.replace( + "/getRoleAssignments", + "/getPaginatedAssignments", + ); + const params = new URLSearchParams({ start, limit: count }); + if (query) params.set("query", query); + if (roleFilters && roleFilters.length > 0) { + params.set( + "filterRole", + roleFilters.map((f) => f.assignType + ":" + f.roleName).join(","), + ); } - 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"); + + // Search display name cache for matches and send as includeSids + if (query) { + const lowerQuery = query.toLowerCase(); + const matchingSids = []; + for (const [key, displayName] of Object.entries(rspDisplayNameCache)) { + if (displayName.toLowerCase().includes(lowerQuery)) { + matchingSids.push(key); + } + } + if (matchingSids.length > 0) { + params.set("includeSids", matchingSids.join(",")); + } } + + return fetch(fetchUrl + "?" + params) + .then((rsp) => rsp.json()) + .catch(() => ({ total: 0, items: [] })); }; +// Legacy client-side data (kept for assignment saving and dialog) +const rspLoadAllAssignments = () => { + const dataHolder = document.getElementById("role-strategy-data"); + if (!dataHolder) return Promise.resolve(); + const fetchUrl = dataHolder.dataset.fetchUrl; + if (!fetchUrl) return Promise.resolve(); + const promises = rspAssignTypes.map((type) => { + const params = new URLSearchParams({ type }); + return fetch(fetchUrl + "?" + params) + .then((rsp) => rsp.json()) + .then((json) => { + rspAssignmentData[type] = json; + }) + .catch(() => { + rspAssignmentData[type] = []; + }); + }); + return Promise.all(promises); +}; -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); +// ============================================ +// Build user summary +// ============================================ + +const rspBuildUserSummary = (userData) => { + const parts = []; + rspAssignTypes.forEach((type) => { + const roles = userData.roles[type]; + if (roles && roles.length > 0) { + parts.push(rspTypeLabels[type] + ": " + roles.join(", ")); } - Behaviour.applySubtree(row, true); + }); + return parts.length > 0 ? parts.join(" \u00B7 ") : ""; +}; + +// ============================================ +// Render user cards +// ============================================ + +const rspGenerateIcon = (type) => { + const icons = document.querySelector("#assign-roles-icons"); + let iconId = "rsp-person-icon"; + if (type === "GROUP") iconId = "rsp-people-icon"; + else if (type === "EITHER") iconId = "rsp-ambiguous-icon"; + return icons.content.querySelector(`#${iconId}`).cloneNode(true); +}; + +// ============================================ +// Pagination +// ============================================ + +const RSP_PAGE_SIZE = 100; +let rspCurrentPage = 0; + +let rspSearchDebounce = null; + +const rspApplyFilterAndPaginate = () => { + // Debounce search to avoid hammering the server + if (rspSearchDebounce) clearTimeout(rspSearchDebounce); + rspSearchDebounce = setTimeout(() => { + rspCurrentPage = 0; + rspRenderCurrentPage(); + }, 300); +}; + +const rspRenderCurrentPage = () => { + rspCancelPendingValidations(); + const container = document.getElementById("rsp-user-cards"); + container.innerHTML = + '
Loading...
'; + + const input = document.querySelector(".rsp-assign-search input"); + const query = input ? input.value.trim() : ""; + const start = rspCurrentPage * RSP_PAGE_SIZE; + + rspFetchPage(start, RSP_PAGE_SIZE, query || null, rspActiveRoleFilters).then( + (data) => { + container.innerHTML = ""; + + data.items.forEach((user) => { + // Convert server format { roles: { globalRoles: [...], ... } } to flat user object + const userData = { + name: user.name, + type: user.type, + roles: user.roles, + }; + // Cache in rspMergedUsers for save compatibility + const key = `${user.type}:${user.name}`; + rspMergedUsers[key] = userData; + rspRenderOneCard(container, userData); + }); + + Behaviour.applySubtree(container, true); + rspUpdateCardBorders(); + // Debounce validation — wait for page to stabilize before firing requests + if (rspValidationDebounce) clearTimeout(rspValidationDebounce); + rspValidationDebounce = setTimeout(rspValidateUserCards, 500); + + const totalFiltered = data.total; + const totalPages = Math.max(1, Math.ceil(totalFiltered / RSP_PAGE_SIZE)); + rspUpdatePaginationUI(totalFiltered, totalPages); + + const emptyState = document.getElementById("rsp-user-empty"); + if (emptyState) emptyState.hidden = totalFiltered > 0; + }, + ); +}; + +const rspUpdatePaginationUI = (totalFiltered, totalPages) => { + // Top count + let topCount = document.getElementById("rsp-result-count"); + if (!topCount) { + topCount = document.createElement("div"); + topCount.id = "rsp-result-count"; + topCount.style.cssText = + "color:var(--text-color-secondary);font-size:0.875rem;margin-bottom:0.5rem;"; + const cardsContainer = document.getElementById("rsp-user-cards"); + cardsContainer.parentNode.insertBefore(topCount, cardsContainer); } -} - - -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; + topCount.textContent = + totalFiltered > 0 + ? totalFiltered.toLocaleString() + + (totalFiltered === 1 ? " result" : " results") + : ""; + + // Bottom pagination + let nav = document.getElementById("rsp-pagination"); + if (!nav) { + nav = document.createElement("div"); + nav.id = "rsp-pagination"; + nav.style.cssText = + "display:flex;align-items:center;justify-content:center;gap:1rem;padding:1rem 0;"; + const cardsContainer = document.getElementById("rsp-user-cards"); + cardsContainer.parentNode.insertBefore(nav, cardsContainer.nextSibling); } - let tooltip = tooltipTemplate.replace("{{PATTERNTEMPLATE}}", escapeHTML(pattern)).replace("{{GRANTBYOTHER}}", ""); - input.nextSibling.setAttribute("data-html-tooltip", tooltip); + if (totalPages <= 1) { + nav.innerHTML = ""; + return; + } - 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+ - } + const fmt = (n) => n.toLocaleString(); + const start = rspCurrentPage * RSP_PAGE_SIZE + 1; + const end = Math.min((rspCurrentPage + 1) * RSP_PAGE_SIZE, totalFiltered); + + nav.innerHTML = ` + + ${fmt(start)}–${fmt(end)} of ${fmt(totalFiltered)} + + `; + + document.getElementById("rsp-page-prev")?.addEventListener("click", () => { + if (rspCurrentPage > 0) { + rspCurrentPage--; + rspRenderCurrentPage(); } - } + }); + document.getElementById("rsp-page-next")?.addEventListener("click", () => { + if (rspCurrentPage < totalPages - 1) { + rspCurrentPage++; + rspRenderCurrentPage(); + } + }); +}; + +const rspRenderOneCard = (container, user) => { + const dataHolder = document.getElementById("role-strategy-data"); + const isBuiltIn = + (user.name === "anonymous" && user.type === "USER") || + (user.name === "authenticated" && user.type === "GROUP"); - if (window.registerTooltips) { - window.registerTooltips(e.nextSibling.parentElement); + const card = document.createElement("div"); + card.classList.add("rsp-card"); + card.dataset.userName = user.name; + card.dataset.userType = user.type; + if (user.type === "EITHER") { + card.classList.add("rsp-card--ambiguous"); } -} + // Hidden validation target + const validationTarget = document.createElement("div"); + validationTarget.classList.add("rsp-card__validation-target"); + validationTarget.style.display = "none"; + card.appendChild(validationTarget); -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"; - } - } - } - if (tbody.children.length >= footerLimit) { - table.tFoot.style.display = "table-footer-group"; - } - } + // 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"); + + // Icon + const iconSpan = document.createElement("span"); + iconSpan.classList.add("rsp-assign__chip-icon"); + iconSpan.appendChild(rspGenerateIcon(user.type)); + if (user.type === "EITHER") { + iconSpan.setAttribute( + "tooltip", + "Ambiguous permission assignment - edit to classify as a user or group", + ); } -); + header.appendChild(iconSpan); -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; + // Name — use cached display name if available + const nameSpan = document.createElement("span"); + nameSpan.classList.add("rsp-card__name"); + const cacheKey = user.type + ":" + user.name; + let displayName = rspDisplayNameCache[cacheKey] || user.name; + if (user.name === "anonymous" && user.type === "USER") + displayName = dataHolder.dataset.textAnonymous; + if (user.name === "authenticated" && user.type === "GROUP") + displayName = dataHolder.dataset.textAuthenticated; + nameSpan.textContent = displayName; + // Only expose the raw id on hover when it differs from what's displayed. + if (displayName !== user.name) { + nameSpan.setAttribute("tooltip", user.name); } - if (findElementsBySelector(tbody, "TR").find(function(n) { - return n.getAttribute("name") == '[' + name + ']'; - }) != null) { - alert("Entry for '" + name + "' already exists"); - return; + header.appendChild(nameSpan); + + // Summary + const summarySpan = document.createElement("span"); + summarySpan.classList.add("rsp-card__summary"); + const summary = rspBuildUserSummary(user); + if (summary) { + summarySpan.textContent = summary; + } else { + summarySpan.textContent = "No roles assigned"; + summarySpan.classList.add("rsp-card__summary--empty"); } - 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; - } - if (tableId === "projectRoles") { - let templateSelect = document.getElementById(tableId + 'template'); - let templateName = templateSelect.value; - if (templateName !== '') { - template = window[templateId + "-" + templateName].content.firstElementChild.cloneNode(true); - } + header.appendChild(summarySpan); + + // Actions — only show if user has edit permissions + const canEdit = dataHolder.dataset.canEdit === "true"; + const actions = document.createElement("div"); + actions.classList.add("rsp-card__actions"); + if (canEdit) { + const editBtn = document.createElement("button"); + editBtn.type = "button"; + editBtn.classList.add( + "jenkins-button", + "jenkins-button--tertiary", + "rsp-card__action", + "rsp-user-edit", + ); + editBtn.setAttribute("tooltip", `Edit ${user.name}`); + const rootUrl = + document.querySelector("[data-rooturl]")?.getAttribute("data-rooturl") || + ""; + editBtn.dataset.type = "dialog-opener"; + editBtn.dataset.dialogUrl = `${rootUrl}/manage/role-strategy/edit-assign-dialog?name=${encodeURIComponent(user.name)}&type=${encodeURIComponent(user.type)}`; + const editIcon = document + .querySelector("#assign-roles-icons") + ?.content.querySelector("#rsp-edit-icon"); + if (editIcon) { + editBtn.appendChild(editIcon.cloneNode(true)); } + actions.appendChild(editBtn); } - - 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; + if (canEdit && !isBuiltIn) { + const deleteBtn = document.createElement("button"); + deleteBtn.type = "button"; + deleteBtn.classList.add( + "jenkins-button", + "jenkins-button--tertiary", + "jenkins-!-destructive-color", + "rsp-card__action", + "rsp-user-delete", + ); + deleteBtn.setAttribute("tooltip", `Remove ${user.name}`); + deleteBtn.innerHTML = + ''; + actions.appendChild(deleteBtn); + } else if (canEdit && isBuiltIn) { + // Spacer to keep edit button aligned with non-built-in users + const spacer = document.createElement("div"); + spacer.classList.add("rsp-card__action"); + actions.appendChild(spacer); } + if (!canEdit) card.classList.add("rsp-card--read-only"); + header.appendChild(actions); + + // Toggle + const toggle = document.createElement("div"); + toggle.classList.add("rsp-card__toggle"); + toggle.innerHTML = + ''; + header.appendChild(toggle); + + card.appendChild(header); + + // Body — lazy loaded on first expand + const body = document.createElement("div"); + body.classList.add("rsp-card__body", "rsp-card__body--collapsed"); + body.dataset.lazy = "true"; + card.appendChild(body); + container.appendChild(card); +}; + +// Lazy-build role checkboxes for a card body +const rspPopulateCardBody = (card) => { + const body = card.querySelector(".rsp-card__body"); + if (!body || body.dataset.lazy !== "true") return; + body.dataset.lazy = "false"; + + const userName = card.dataset.userName; + const userType = card.dataset.userType; + const key = `${userType}:${userName}`; + const user = rspMergedUsers[key]; + if (!user) return; + + const roleSection = document.createElement("div"); + roleSection.classList.add("rsp-perm"); + roleSection.style.padding = "0.75rem"; + + rspAssignTypes.forEach((type) => { + const roles = rspRoleDefinitions[type]; + if (!roles || roles.length === 0) return; + const assignedRoleNames = user.roles[type] || []; + const assignedRoles = roles.filter((role) => + assignedRoleNames.includes(role.name), + ); + if (assignedRoles.length === 0) return; + + const group = document.createElement("fieldset"); + group.classList.add("rsp-perm__group"); - let children = copy.childNodes - children.forEach(function(item){ - item.outerHTML= item.outerHTML.replace(/{{ROLE}}/g, doubleEscapeHTML(name)).replace(/{{PATTERN}}/g, doubleEscapeHTML(pattern)); + const legend = document.createElement("legend"); + legend.classList.add("rsp-perm__group-title"); + legend.textContent = rspTypeLabels[type] + " roles"; + group.appendChild(legend); + + const perms = document.createElement("div"); + perms.classList.add("rsp-perm__permissions"); + + assignedRoles.forEach((role) => { + const label = document.createElement("label"); + label.classList.add("rsp-perm__item"); + label.dataset.roleName = role.name; + label.dataset.assignType = type; + + if (role.permissions && role.permissions.length > 0) { + const items = role.permissions + .map((p) => `
  • ${escapeHTML(p)}
  • `) + .join(""); + label.dataset.htmlTooltip = + `Permissions` + + `
      ${items}
    `; + } + + const cb = document.createElement("input"); + cb.type = "checkbox"; + cb.dataset.roleName = role.name; + cb.dataset.assignType = type; + cb.checked = true; + label.appendChild(cb); + + const nameSpan = document.createElement("span"); + nameSpan.classList.add("rsp-perm__item-name"); + nameSpan.textContent = role.name; + label.appendChild(nameSpan); + + if (role.pattern) { + const patternSpan = document.createElement("span"); + patternSpan.classList.add("rsp-assign-dialog__role-pattern"); + patternSpan.textContent = ` "${role.pattern}"`; + label.appendChild(patternSpan); + } + + perms.appendChild(label); + }); + + group.appendChild(perms); + roleSection.appendChild(group); }); - if (tableId !== "globalRoles") { - spanElement = copy.childNodes[2].childNodes[0].childNodes[1]; - if (tableId === "projectRoles") { - bindListenerToPattern(spanElement.childNodes[0]); - } else { - bindAgentListenerToPattern(spanElement.childNodes[0]); - } + body.appendChild(roleSection); + Behaviour.applySubtree(body, true); +}; + +// Initial render — just fetch first page +const rspRenderUserCards = () => { + rspCurrentPage = 0; + rspRenderCurrentPage(); +}; + +// ============================================ +// User/group validation against security realm +// ============================================ + +const rspProcessValidation = (card) => { + const target = card.querySelector(".rsp-card__validation-target"); + if (!target) return; + + const nameEl = card.querySelector(".rsp-card__name"); + + // Check for not-found state + const notFound = target.querySelector(".rsp-entry-not-found"); + if (notFound) { + card.classList.add("rsp-card--not-found"); + } else { + card.classList.remove("rsp-card--not-found"); } - copy.setAttribute("name", '[' + name + ']'); - if (tableId !== "globalRoles") { - copy.querySelector("svg.icon-pencil").onclick = handlePatternEdit; + // Check for warning state + const warningCell = target.querySelector(".rsp-table__icon-alert"); + if (warningCell) { + card.classList.add("rsp-card--warning"); } - 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); - } + + // Extract display name from the validation response + const responseDiv = target.querySelector(".rsp-table__cell"); + if (responseDiv && nameEl) { + // The response contains icon SVGs + text. Get the text content after icons. + const textNodes = []; + responseDiv.childNodes.forEach((node) => { + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent.trim(); + if (text) textNodes.push(text); + } else if (node.tagName === "SPAN") { + const text = node.textContent.trim(); + if (text) textNodes.push(text); } + }); + const displayName = textNodes.join("").trim(); + if (displayName && displayName !== card.dataset.userName) { + nameEl.textContent = displayName; + // Show the raw id on hover only when it's different from the displayed name. + nameEl.setAttribute("tooltip", card.dataset.userName); + // Cache the resolved display name for search + const cacheKey = card.dataset.userType + ":" + card.dataset.userName; + rspDisplayNameCache[cacheKey] = displayName; + } else if (displayName === card.dataset.userName) { + // Display name matches the id — no need for a tooltip. + nameEl.removeAttribute("tooltip"); } - if (parent.children.length < footerLimit) { - table.tFoot.style.display = "none"; + + // If the validation response carries a tooltip (e.g. a warning), prefer it. + const tooltip = responseDiv.getAttribute("tooltip"); + if (tooltip) { + nameEl.setAttribute("tooltip", tooltip); } - let dirtyButton = document.getElementById("rs-dirty-indicator"); - dirtyButton.dispatchEvent(new Event('click')); - return false; } -}); +}; -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 - return; +// Track active validation so we can abort on page change +let rspValidationAbortController = null; +let rspValidationDebounce = null; + +const rspCancelPendingValidations = () => { + if (rspValidationAbortController) { + rspValidationAbortController.abort(); + rspValidationAbortController = null; } + if (rspValidationDebounce) { + clearTimeout(rspValidationDebounce); + rspValidationDebounce = null; + } +}; + +const rspValidateUserCards = () => { + const dataHolder = document.getElementById("role-strategy-data"); + const descriptorUrl = dataHolder?.dataset.descriptorUrl; + if (!descriptorUrl) return; + + // Abort any previous validation batch + if (rspValidationAbortController) rspValidationAbortController.abort(); + rspValidationAbortController = new AbortController(); + const signal = rspValidationAbortController.signal; + + // Collect cards to validate + const cards = []; + document.querySelectorAll("#rsp-user-cards .rsp-card").forEach((card) => { + const userName = card.dataset.userName; + const userType = card.dataset.userType; + if (!userName || !userType) return; + if (userName === "anonymous" && userType === "USER") return; + if (userName === "authenticated" && userType === "GROUP") return; + if (!card.querySelector(".rsp-card__validation-target")) return; + cards.push(card); + }); + + const maxParallel = isHttp2Enabled() ? 30 : 1; - 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; + const validateCard = (card) => { + if (signal.aborted) return Promise.resolve(); + const target = card.querySelector(".rsp-card__validation-target"); + const checkValue = + "[" + card.dataset.userType + ":" + card.dataset.userName + "]"; + const checkUrl = + descriptorUrl + "/checkName?value=" + encodeURIComponent(checkValue); + return fetch(checkUrl, { method: "POST", headers: crumb.wrap({}), signal }) + .then((rsp) => rsp.text()) + .then((html) => { + if (signal.aborted) return; + target.innerHTML = html; + rspProcessValidation(card); + }) + .catch(() => {}); }; -}); + // Process in batches of maxParallel + let idx = 0; + const processNext = () => { + if (signal.aborted || idx >= cards.length) return Promise.resolve(); + const batch = cards.slice(idx, idx + maxParallel); + idx += maxParallel; + return Promise.all(batch.map(validateCard)).then(processNext); + }; + processNext(); +}; + +// ============================================ +// Save assignments +// ============================================ + +const rspDeleteAssignment = (userName, userType) => { + const dataHolder = document.getElementById("role-strategy-data"); + const formData = new FormData(); + formData.append("json", JSON.stringify({ name: userName, type: userType })); + return fetch(dataHolder.dataset.deleteAssignUrl, { + method: "POST", + headers: crumb.wrap({}), + body: formData, + }).then((rsp) => { + if (!rsp.ok) throw new Error("Failed to delete 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 +let rspAutoSaveTimer = null; +const rspAutoSave = () => { + if (rspAutoSaveTimer) clearTimeout(rspAutoSaveTimer); + rspAutoSaveTimer = setTimeout(() => { + rspSaveAssignments().catch((err) => { + notificationBar.show( + "Failed to save: " + err.message, + notificationBar.ERROR, + ); + }); + }, 500); +}; + +// ============================================ +// Search +// ============================================ + +// Active role filters: [{ assignType, roleName }, ...] +let rspActiveRoleFilters = []; + +const rspApplyUserFilters = () => { + rspApplyFilterAndPaginate(); + + // Update filter button active state + const filterBtn = document.querySelector(".rsp-role-filter-btn"); + if (filterBtn) { + const active = rspActiveRoleFilters.length > 0; + filterBtn.classList.toggle("jenkins-button--tertiary", !active); + filterBtn.classList.toggle("jenkins-!-accent-color", active); } + const resetBtn = document.querySelector(".rsp-role-filter-reset"); + if (resetBtn) resetBtn.hidden = rspActiveRoleFilters.length === 0; +}; - fetch(url + toQueryString(reqParams)).then((rsp) => { - if (rsp.ok) { - rsp.json().then((responseJson) => { - let matchingItems = responseJson.matchingJobs; - let itemCount = responseJson.itemCount; +const rspPopulateRoleFilter = () => { + const list = document.querySelector(".rsp-role-filter-list"); + if (!list) return; + list.innerHTML = ""; - if (matchingItems != null) { - showItemsModal(matchingItems, itemCount, maxItems, pattern); - } else { - showErrorMessageModal(); - } + 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("jenkins-dropdown__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(); }); - } else { - showErrorMessageModal(); - } + + list.appendChild(item); + }); }); -} +}; -showItemsModal = function(items, itemCount, maxItems, pattern) { - let modalTitle = ''; +// 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; - if (items.length > 0) { - if (itemCount > items.length) { - modalTitle += 'First ' + maxItems + ' items (out of ' + itemCount + ') matching'; - } else { - modalTitle += 'Items matching'; - } - } else { - modalTitle = 'No items found matching'; + const searchInput = dropdown.querySelector(".rsp-role-filter-search input"); + const resetBtn = dropdown.querySelector(".rsp-role-filter-reset"); + + // Search within dropdown + const applyFilterSearch = () => { + const q = searchInput ? searchInput.value.toLowerCase().trim() : ""; + dropdown.querySelectorAll(".jenkins-dropdown__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("jenkins-dropdown__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 (searchInput) { + searchInput.addEventListener("input", applyFilterSearch); + searchInput.addEventListener("click", (e) => e.stopPropagation()); } - 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 + + 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(); + }); } - fetch(url + toQueryString(reqParams)).then((rsp) => { - if (rsp.ok) { - rsp.json().then((responseJson) => { - let matchingAgents = responseJson.matchingAgents; - let agentCount = responseJson.itemCount; + // Toggle dropdown + btn.addEventListener("click", (e) => { + e.stopPropagation(); + const isOpen = !dropdown.hidden; + dropdown.hidden = isOpen; + btn.setAttribute("aria-expanded", String(!isOpen)); - if (matchingAgents != null) { - showAgentsModal(matchingAgents, agentCount, maxAgents, pattern); - } else { - showAgentErrorMessageModal(); + 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(); } - }); - } else { - showAgentErrorMessageModal(); + }; + setTimeout(() => { + document.addEventListener("click", clickHandler); + document.addEventListener("keydown", escHandler); + }, 0); } }); -} - -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'; - } - } else { - modalTitle += 'No Agents found matching'; - } - 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")); - } - dialog.modal(messageElement, {title: title}); -} +}; + +// ============================================ +// Behaviours +// ============================================ + +// Card header toggle +Behaviour.specify( + "#rsp-user-cards .rsp-card__header", + "RoleStrategyAssign", + 0, + (header) => { + if (header.dataset.initialized === "true") return; + header.dataset.initialized = "true"; -showAgentErrorMessageModal = function() { - dialogalert('Unable to fetch matching Agents.'); -} + 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) return; + // Don't expand cards with no roles assigned + const summary = card.querySelector(".rsp-card__summary"); + if (summary && summary.classList.contains("rsp-card__summary--empty")) + return; + const body = card.querySelector(".rsp-card__body"); + const isExpanded = !body.classList.contains("rsp-card__body--collapsed"); + // Lazy-load role checkboxes on first expand + if (!isExpanded) rspPopulateCardBody(card); + body.classList.toggle("rsp-card__body--collapsed"); + header.setAttribute("aria-expanded", String(!isExpanded)); + card.setAttribute("aria-expanded", String(!isExpanded)); + }; + + header.addEventListener("click", handleToggle); + header.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleToggle(e); + } + }); + }, +); + +// Role checkbox in card body — view only (editing via dialog) +Behaviour.specify( + "#rsp-user-cards .rsp-perm__item input[type=checkbox]", + "RoleStrategyAssign", + 0, + (cb) => { + if (cb.dataset.initialized === "true") return; + cb.dataset.initialized = "true"; + // Prevent changes — card body is read-only, editing is done via the edit dialog + cb.addEventListener("change", () => { + cb.checked = !cb.checked; + }); + }, +); -bindAgentListenerToPattern = function(elem) { - elem.addEventListener('click', showMatchingAgents); -} +// Delete user +Behaviour.specify(".rsp-user-delete", "RoleStrategyAssign", 0, (btn) => { + if (btn.dataset.initialized === "true") return; + btn.dataset.initialized = "true"; -document.addEventListener('DOMContentLoaded', function() { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + const card = btn.closest(".rsp-card"); + if (!card) return; + const userName = card.dataset.userName; + const userType = card.dataset.userType; - // global roles initialization - const globalTable = document.getElementById("globalRoles"); - if (globalTable) { - const readOnly = globalTable.classList.contains("read-only"); + dialog + .confirm(`Remove all role assignments for "${userName}"?`, { + type: "destructive", + okText: "Remove", + }) + .then(() => { + // Remove from all assignment data + rspAssignTypes.forEach((type) => { + const json = rspAssignmentData[type]; + if (!json) return; + const idx = json.findIndex( + (e) => e.name === userName && e.type === userType, + ); + if (idx !== -1) json.splice(idx, 1); + }); - let globalRoleInputFilter = document.getElementById('globalRoleInputFilter'); - if (globalRoleInputFilter && parseInt(globalRoleInputFilter.getAttribute("data-initial-size")) >= filterLimit) { - globalRoleInputFilter.style.display = "block" + delete rspMergedUsers[`${userType}:${userName}`]; + card.remove(); + rspUpdateCardBorders(); + + rspDeleteAssignment(userName, userType) + .then(() => { + notificationBar.show( + `Removed "${userName}"`, + notificationBar.SUCCESS, + ); + }) + .catch((err) => { + notificationBar.show( + "Failed to delete: " + err.message, + notificationBar.ERROR, + ); + }); + }) + .catch(() => {}); + }); +}); + +// Edit user assignments — dialog opened via data-type="dialog-opener" +Behaviour.specify(".rsp-user-edit", "RoleStrategyAssign", 0, (btn) => { + if (btn.dataset.initialized === "true") return; + btn.dataset.initialized = "true"; + + btn.addEventListener("click", (e) => { + e.stopPropagation(); + const initDialog = () => { + const form = document.querySelector("form[name='editAssignRoles']"); + if (!form) { + setTimeout(initDialog, 100); + return; + } + if (form.dataset.dialogInit === "true") return; + form.dataset.dialogInit = "true"; + + const originalTypeInput = form.querySelector("input[name='originalType']"); + const isAmbiguous = + originalTypeInput && originalTypeInput.value === "EITHER"; + + const validateAndSubmit = () => { + if (isAmbiguous) { + const picked = form.querySelector("input[name='type']:checked"); + if (!picked) { + notificationBar.show( + "Select User or Group to classify this ambiguous SID.", + notificationBar.ERROR, + ); + return; + } + } + form.requestSubmit(); + }; + + const submitBtn = form.querySelector("#rsp-edit-assign-submit-btn"); + if (submitBtn) { + submitBtn.addEventListener("click", validateAndSubmit); + } + form.addEventListener("keydown", (ev) => { + if (ev.key === "Enter") { + ev.preventDefault(); + validateAndSubmit(); + } + }); + }; + setTimeout(initDialog, 200); + }); +}); + +// Assign role button — dialog opened via data-type="dialog-opener" + +// Look up existing role assignments for a given name + type. +// Returns { globalRoles: [...], projectRoles: [...], slaveRoles: [...] } or null if none. +const rspFindExistingAssignments = (name, type) => { + if (!name || !type) return null; + const result = {}; + let found = false; + rspAssignTypes.forEach((assignType) => { + const list = rspAssignmentData[assignType]; + if (!list) return; + const entry = list.find((e) => e.name === name && e.type === type); + if (entry && entry.roles && entry.roles.length > 0) { + result[assignType] = entry.roles; + found = true; } - newGlobalRoleTemplate = document.getElementById('newGlobalRoleTemplate'); + }); + return found ? result : null; +}; + +// Resolve the typed name against the security realm and show the display name (or "not found"). +// Uses the same descriptor/checkName endpoint as the assignments cards. +const rspValidateAssignName = (form, getAbort, setAbort) => { + const nameInput = form.querySelector("input[name='name']"); + const target = form.querySelector(".rsp-assign-name-validation"); + if (!nameInput || !target) return; + const name = nameInput.value.trim(); + const typeInput = form.querySelector("input[name='type']:checked"); + const type = typeInput ? typeInput.value : null; - globalTableHighlighter = new TableHighlighter('globalRoles', readOnly ? 1 : 2); + // Cancel any in-flight check + const prev = getAbort?.(); + if (prev) prev.abort(); + + if (!name || !type) { + target.hidden = true; + target.innerHTML = ""; + return; } - // item roles initialization - const projectRolesTable = document.getElementById('projectRoles'); - if (projectRolesTable) { - const readOnly = projectRolesTable.classList.contains("read-only"); + const dataHolder = document.getElementById("role-strategy-data"); + const descriptorUrl = dataHolder?.dataset.descriptorUrl; + if (!descriptorUrl) return; - let itemRoleInputFilter = document.getElementById('itemRoleInputFilter'); - if (itemRoleInputFilter && parseInt(itemRoleInputFilter.getAttribute("data-initial-size")) >= filterLimit ) { - itemRoleInputFilter.style.display = "block" - } + const controller = new AbortController(); + setAbort?.(controller); - newItemRoleTemplate = document.getElementById('newItemRoleTemplate'); + const value = "[" + type + ":" + name + "]"; + const url = descriptorUrl + "/checkName?value=" + encodeURIComponent(value); + fetch(url, { + method: "POST", + headers: crumb.wrap({}), + signal: controller.signal, + }) + .then((rsp) => rsp.text()) + .then((html) => { + target.innerHTML = html; + target.hidden = false; + }) + .catch(() => { + target.hidden = true; + target.innerHTML = ""; + }); +}; - projectTableHighlighter = new TableHighlighter('projectRoles', readOnly ? 3 : 4); +const rspUpdateAssignNameWarning = (form) => { + const nameInput = form.querySelector("input[name='name']"); + const feedback = form.querySelector(".rsp-assign-name-feedback"); + if (!nameInput || !feedback) return; + const name = nameInput.value.trim(); + const typeInput = form.querySelector("input[name='type']:checked"); + const type = typeInput ? typeInput.value : null; - // Show jobs matching a pattern on click - let itemPatterns = projectRolesTable.getElementsByClassName('patternAnchor'); - for (let pattern of itemPatterns) { - bindListenerToPattern(pattern); - } + const existing = rspFindExistingAssignments(name, type); + if (!existing) { + feedback.hidden = true; + feedback.innerHTML = ""; + return; } - // agent roles initialization - const agentRolesTable = document.getElementById('agentRoles'); - if (agentRolesTable) { - const readOnly = agentRolesTable.classList.contains("read-only"); + const parts = []; + rspAssignTypes.forEach((assignType) => { + const roles = existing[assignType]; + if (!roles) return; + parts.push( + `
  • ${escapeHTML(rspTypeLabels[assignType])}: ${roles.map(escapeHTML).join(", ")}
  • `, + ); + }); + feedback.innerHTML = + `
    ` + + `This ${type === "GROUP" ? "group" : "user"} already has role assignments. ` + + `New roles will be added to the existing ones.` + + `
      ${parts.join("")}
    ` + + `
    `; + feedback.hidden = false; +}; + +// Assign role dialog submit button + enter key +Behaviour.specify( + "#rsp-assign-role-submit-btn", + "RoleStrategyAssign", + 0, + (btn) => { + if (btn.dataset.initialized === "true") return; + btn.dataset.initialized = "true"; - newAgentRoleTemplate = document.getElementById('newAgentRoleTemplate'); + const form = btn.closest("form"); + if (!form) return; - 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); + // Live warning when the typed name matches an existing user/group with assignments + // + display-name resolution against the security realm. + const nameInput = form.querySelector("input[name='name']"); + if (nameInput) { + let warnTimer = null; + let validationAbort = null; + const scheduleWarning = () => { + if (warnTimer) clearTimeout(warnTimer); + warnTimer = setTimeout(() => { + rspUpdateAssignNameWarning(form); + rspValidateAssignName(form, () => validationAbort, (c) => { + validationAbort = c; + }); + }, 300); + }; + nameInput.addEventListener("input", scheduleWarning); + form.querySelectorAll("input[name='type']").forEach((r) => { + r.addEventListener("change", scheduleWarning); + }); } - } -}); \ No newline at end of file + + const validateAndSubmit = () => { + const nameInput = form.querySelector("input[name='name']"); + if (!nameInput || !nameInput.value.trim()) { + nameInput?.focus(); + if (nameInput) { + nameInput.style.outline = "2px solid var(--error-color)"; + nameInput.addEventListener( + "input", + () => { + nameInput.style.outline = ""; + }, + { once: true }, + ); + } + return; + } + form.requestSubmit(); + }; + + btn.addEventListener("click", validateAndSubmit); + + // Enter key anywhere in the form triggers submit + form.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + validateAndSubmit(); + } + }); + }, +); + +// Role dialog filter +Behaviour.specify( + ".rsp-role-dialog-filter input", + "RoleStrategyAssign", + 0, + (input) => { + if (input.dataset.initialized === "true") return; + input.dataset.initialized = "true"; + input.addEventListener("input", () => { + const q = input.value.toLowerCase().trim(); + const container = input + .closest(".jenkins-form-item") + ?.querySelector(".rsp-assign-dialog__roles"); + if (!container) return; + + let visibleCount = 0; + container + .querySelectorAll(".rsp-assign-dialog__role-item") + .forEach((item) => { + const match = + q === "" || (item.dataset.roleName || "").toLowerCase().includes(q); + item.style.display = match ? "" : "none"; + if (match) visibleCount++; + }); + + container + .querySelectorAll(".rsp-assign-dialog__group-title") + .forEach((title) => { + const next = title.nextElementSibling; + let hasVisible = false; + if (next && next.classList.contains("rsp-assign-dialog__group")) { + next + .querySelectorAll(".rsp-assign-dialog__role-item") + .forEach((child) => { + if (child.style.display !== "none") hasVisible = true; + }); + } + title.style.display = hasVisible ? "" : "none"; + }); + + const noResults = container.querySelector( + ".rsp-assign-dialog__no-results", + ); + if (noResults) + noResults.classList.toggle( + "jenkins-hidden", + visibleCount > 0 || q === "", + ); + }); + }, +); + +// Search +Behaviour.specify( + ".rsp-assign-search input", + "RoleStrategyAssign", + 0, + (input) => { + if (input.dataset.initialized === "true") return; + input.dataset.initialized = "true"; + input.addEventListener("input", rspApplyUserFilters); + }, +); + +// ============================================ +// Initialization +// ============================================ + +document.addEventListener("DOMContentLoaded", () => { + rspLoadRoleDefinitions(); + rspPopulateRoleFilter(); + rspInitRoleFilterDropdown(); + // Load assignments for save compatibility (background), render via paginated endpoint + rspLoadAllAssignments(); + rspRenderUserCards(); +}); diff --git a/src/main/webapp/js/tableRoles.js b/src/main/webapp/js/tableRoles.js new file mode 100644 index 00000000..bdc4a648 --- /dev/null +++ b/src/main/webapp/js/tableRoles.js @@ -0,0 +1,1864 @@ +/* + * The MIT License + * + * 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 + * 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. + */ + +// ============================================ +// 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"); + + // Lazy-load: move content from