diff --git a/shell/assets/translations/en-us.yaml b/shell/assets/translations/en-us.yaml
index 7d037bb18b0..802aa696054 100644
--- a/shell/assets/translations/en-us.yaml
+++ b/shell/assets/translations/en-us.yaml
@@ -483,6 +483,8 @@ addonConfigConfirmation:
addProjectMemberDialog:
title: Add Project Member
+ duplicateRolesError: This member already has all of the selected roles in this project. No new assignments were created.
+ duplicateRolesSkipped: "One or more selected roles were skipped because they are already assigned to this member in this project."
authConfig:
slo:
diff --git a/shell/components/ExplorerMembers.vue b/shell/components/ExplorerMembers.vue
index 0ba95cd155c..2b002c2fb4a 100644
--- a/shell/components/ExplorerMembers.vue
+++ b/shell/components/ExplorerMembers.vue
@@ -62,17 +62,40 @@ export default {
});
}
+ // Fetch projects first so we can scope the PRTB request to this cluster's projects only.
+ // Fetching all PRTBs globally without a filter causes the Norman API's page limit to be
+ // hit before cluster-specific results are loaded, resulting in most bindings being invisible
+ // after client-side filtering.
+ const allProjects = await this.$store.dispatch('management/findAll', { type: MANAGEMENT.PROJECT });
+
+ this['projects'] = allProjects;
+
if (projectRoleTemplateBindingSchema) {
- this.$store.dispatch('rancher/findAll', { type: NORMAN.PROJECT_ROLE_TEMPLATE_BINDING, opt: { force: true } }, { root: true })
- .then((bindings) => {
- this['projectRoleTemplateBindings'] = bindings;
- this.loadingProjectBindings = false;
- });
+ const clusterId = this.$store.getters['currentCluster'].id;
+ // Norman projectId format is 'clusterId:projectNamespace' (e.g. 'local:p-v679w').
+ // Management project id format is 'clusterId/projectNamespace' — replace '/' with ':'.
+ // projectId is a proper Norman reference field and is safe to filter on; namespaceId is
+ // a mapper-moved field that may not be indexed for filtering.
+ const projectIds = allProjects
+ .filter((p) => p?.spec?.clusterName === clusterId)
+ .map((p) => p.id.replace('/', ':'))
+ .filter(Boolean);
+
+ if (projectIds.length > 0) {
+ // Norman's _in filter does not split comma-separated values — each value must be
+ // a separate repeated query parameter (e.g. ?projectId_in=a&projectId_in=b).
+ const url = `/v3/projectroletemplatebindings?${ projectIds.map((id) => `projectId_in=${ encodeURIComponent(id) }`).join('&') }`;
+
+ this.$store.dispatch('rancher/findAll', { type: NORMAN.PROJECT_ROLE_TEMPLATE_BINDING, opt: { force: true, url } }, { root: true })
+ .then((bindings) => {
+ this['projectRoleTemplateBindings'] = bindings;
+ this.loadingProjectBindings = false;
+ });
+ } else {
+ this.loadingProjectBindings = false;
+ }
}
- this.$store.dispatch('management/findAll', { type: MANAGEMENT.PROJECT })
- .then((projects) => (this['projects'] = projects));
-
const hydration = {
normanPrincipals: this.$store.dispatch('rancher/findAll', { type: NORMAN.PRINCIPAL }),
mgmt: this.$store.dispatch(`management/findAll`, { type: MANAGEMENT.USER }),
@@ -186,7 +209,7 @@ export default {
});
// We need to group each of the TemplateRoleBindings by the user + project
- const userRoles = [...fakeRows, ...this.filteredProjectRoleTemplateBindings].reduce((rows, curr) => {
+ const userRoles = this.filteredProjectRoleTemplateBindings.reduce((rows, curr) => {
const {
userId, groupPrincipalId, roleTemplate, projectId
} = curr;
@@ -211,7 +234,9 @@ export default {
return rows;
}, {});
- return Object.values(userRoles);
+ // fakeRows must be added outside the reduce — they have no userId/groupPrincipalId
+ // and would be silently dropped if included in the reduce input.
+ return [...Object.values(userRoles), ...fakeRows];
},
canManageMembers() {
return canViewClusterPermissionsEditor(this.$store);
diff --git a/shell/config/product/explorer.js b/shell/config/product/explorer.js
index 5e8b33e5091..fbb45b7b4ab 100644
--- a/shell/config/product/explorer.js
+++ b/shell/config/product/explorer.js
@@ -191,7 +191,7 @@ export function init(store) {
configureType(MANAGEMENT.PROJECT_ROLE_TEMPLATE_BINDING, { isEditable: false, depaginate: dePaginateBindings });
configureType(MANAGEMENT.PROJECT, { displayName: store.getters['i18n/t']('namespace.project.label') });
configureType(NORMAN.CLUSTER_ROLE_TEMPLATE_BINDING, { depaginate: dePaginateNormanBindings });
- configureType(NORMAN.PROJECT_ROLE_TEMPLATE_BINDING, { depaginate: dePaginateNormanBindings });
+ configureType(NORMAN.PROJECT_ROLE_TEMPLATE_BINDING, { depaginate: true });
configureType(SNAPSHOT, { depaginate: true });
configureType(SECRET, { showListMasthead: false });
diff --git a/shell/dialog/AddProjectMemberDialog.vue b/shell/dialog/AddProjectMemberDialog.vue
index f013750d411..eac555c0239 100644
--- a/shell/dialog/AddProjectMemberDialog.vue
+++ b/shell/dialog/AddProjectMemberDialog.vue
@@ -45,7 +45,8 @@ export default {
principalId: '',
roleTemplateIds: []
},
- error: null
+ error: null,
+ duplicateWarning: null
};
},
@@ -77,12 +78,35 @@ export default {
this.close();
},
+ // Returns the subset of roleTemplateIds that already exist for this principal+project.
+ existingRoleTemplateIds(principalProperty, principalId) {
+ const all = this.$store.getters['rancher/all'](NORMAN.PROJECT_ROLE_TEMPLATE_BINDING);
+
+ return this.member.roleTemplateIds.filter((roleTemplateId) => all.some(
+ (b) => b.projectId === this.projectId &&
+ b.roleTemplateId === roleTemplateId &&
+ b[principalProperty] === principalId
+ ));
+ },
+
async createBindings() {
const principalProperty = await this.principalProperty();
- const promises = this.member.roleTemplateIds.map((roleTemplateId) => this.$store.dispatch(`rancher/create`, {
+ const principalId = this.member.principalId;
+ const duplicates = this.existingRoleTemplateIds(principalProperty, principalId);
+ const newRoleTemplateIds = this.member.roleTemplateIds.filter((id) => !duplicates.includes(id));
+
+ if (newRoleTemplateIds.length === 0) {
+ throw new Error(this.t('addProjectMemberDialog.duplicateRolesError'));
+ }
+
+ if (duplicates.length > 0) {
+ this.duplicateWarning = this.t('addProjectMemberDialog.duplicateRolesSkipped');
+ }
+
+ const promises = newRoleTemplateIds.map((roleTemplateId) => this.$store.dispatch(`rancher/create`, {
type: NORMAN.PROJECT_ROLE_TEMPLATE_BINDING,
roleTemplateId,
- [principalProperty]: this.member.principalId,
+ [principalProperty]: principalId,
projectId: this.projectId,
}));
@@ -91,6 +115,7 @@ export default {
saveBindings(btnCB) {
this.error = null;
+ this.duplicateWarning = null;
this.createBindings()
.then((bindings) => {
return Promise.all(bindings.map((b) => b.save()));
@@ -129,6 +154,12 @@ export default {
>
{{ error }}
+
+ {{ duplicateWarning }}
+