Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions shell/assets/translations/en-us.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
45 changes: 35 additions & 10 deletions shell/components/ExplorerMembers.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion shell/config/product/explorer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
37 changes: 34 additions & 3 deletions shell/dialog/AddProjectMemberDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ export default {
principalId: '',
roleTemplateIds: []
},
error: null
error: null,
duplicateWarning: null
};
},

Expand Down Expand Up @@ -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,
}));

Expand All @@ -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()));
Expand Down Expand Up @@ -129,6 +154,12 @@ export default {
>
{{ error }}
</Banner>
<Banner
v-if="duplicateWarning"
color="warning"
>
{{ duplicateWarning }}
</Banner>
<ProjectMemberEditor
v-model:value="member"
:use-two-columns-for-custom="true"
Expand Down