diff --git a/src/components/DropdownSelector/DropdownSelector.vue b/src/components/DropdownSelector/DropdownSelector.vue index c32b511f7e..685c361458 100644 --- a/src/components/DropdownSelector/DropdownSelector.vue +++ b/src/components/DropdownSelector/DropdownSelector.vue @@ -80,6 +80,16 @@ const props = defineProps({ disabled: { type: Boolean }, + /** + * Extra attributes (e.g. title, aria-label) applied directly to the toggle button. A `title` + * bound on this component itself would only reach b-dropdown's outer wrapper (it sets + * inheritAttrs: false and spreads attrs there, not on the button), never the focusable toggle - + * this is the one prop b-dropdown actually forwards to the button itself. + */ + toggleAttrs: { + type: Object, + default: () => ({}) + }, /** * Hide the caret in the toggler. */ @@ -290,6 +300,7 @@ defineExpose({ hide, focus }) class="dropdown-selector" :disabled="disabled" :placement="placement" + :toggle-attrs="toggleAttrs" no-caret menu-class="dropdown-selector__menu" toggle-class="d-inline-flex align-items-center p-2 text-body" diff --git a/src/components/Project/ProjectDropdownSelector/ProjectDropdownSelector.vue b/src/components/Project/ProjectDropdownSelector/ProjectDropdownSelector.vue index f4f8b7cb37..84d192d729 100644 --- a/src/components/Project/ProjectDropdownSelector/ProjectDropdownSelector.vue +++ b/src/components/Project/ProjectDropdownSelector/ProjectDropdownSelector.vue @@ -35,6 +35,15 @@ const props = defineProps({ disabled: { type: Boolean }, + /** + * Title applied directly to the toggle button (not just a wrapping element), so it reaches a + * keyboard or screen-reader user focusing the button itself - typically used to explain why the + * dropdown is disabled. + */ + title: { + type: String, + default: null + }, /** * Hide the caret in the toggler. */ @@ -115,6 +124,7 @@ function filterProject(project, query) { pin-selected flush-items :disabled="disabled" + :toggle-attrs="title ? { title } : undefined" :no-caret="noCaret" :options="projects" :teleport-to="teleportTo" diff --git a/src/components/Project/ProjectRow/ProjectRowUserRole.vue b/src/components/Project/ProjectRow/ProjectRowUserRole.vue index 1e28d0e92a..9585f85fe6 100644 --- a/src/components/Project/ProjectRow/ProjectRowUserRole.vue +++ b/src/components/Project/ProjectRow/ProjectRowUserRole.vue @@ -19,7 +19,6 @@ const userRole = computed(() => getRoleByProject(props.project.name)) diff --git a/src/components/ProjectUsers/ProjectUsersList.vue b/src/components/ProjectUsers/ProjectUsersList.vue index 9c8f401457..2b8dbdba7c 100644 --- a/src/components/ProjectUsers/ProjectUsersList.vue +++ b/src/components/ProjectUsers/ProjectUsersList.vue @@ -1,10 +1,10 @@ + + diff --git a/src/views/Settings/SettingsView/SettingsViewUsersRolesModal.vue b/src/views/Settings/SettingsView/SettingsViewUsersRolesModal.vue index 2e8c580709..b0145b0559 100644 --- a/src/views/Settings/SettingsView/SettingsViewUsersRolesModal.vue +++ b/src/views/Settings/SettingsView/SettingsViewUsersRolesModal.vue @@ -16,8 +16,10 @@ import ProjectButton from '@/components/Project/ProjectButton.vue' import ProjectDropdownSelector from '@/components/Project/ProjectDropdownSelector/ProjectDropdownSelector.vue' import ProjectUsersRoleDropdown from '@/components/ProjectUsers/ProjectUsersRoleDropdown.vue' import SettingsViewUsersNotFound from '@/views/Settings/SettingsView/SettingsViewUsersNotFound.vue' +import SettingsViewUsersRolesCascadeModal from '@/views/Settings/SettingsView/SettingsViewUsersRolesCascadeModal.vue' import { useAuth } from '@/composables/useAuth.js' +import { useConfirmModal } from '@/composables/useConfirmModal.js' import { usePolicies } from '@/composables/usePolicies.js' import { useCore } from '@/composables/useCore.js' import { useToast } from '@/composables/useToast.js' @@ -54,6 +56,7 @@ const core = useCore() const { toast } = useToast() const { t } = useI18n() const { isInstanceAdmin, formatRole } = usePolicies() +const { confirm } = useConfirmModal() // Under OAuth, project membership comes from the identity provider's groups and is reconciled at // each login: a project role revoked here comes back, and one granted on a project the provider // does not list goes away. The role level on a project it does list is kept, so it can still be @@ -71,6 +74,14 @@ watch(() => props.user?.uid, () => { resetAddForm() }) +// The modal only ever shows whatever `roles` its `user` prop was given, which the parent only +// refreshes when this modal itself grants/revokes something (`user:updated`). A role changed +// elsewhere (another admin tab, the CLI) while this modal happened to be open would go unnoticed +// until something inside it triggers that refresh - so ask for one on every open too. +watch(modelValue, (visible) => { + if (visible) emit('user:updated', { uid: props.user.uid }) +}) + // Each permission is { v1: role, v2: 'domain::project' }, parsed into { role, domain, project }: the // domain is sent when revoking a domain admin grant and orders the rows. Sorted by role rank, then // A-Z (see compareGrants). @@ -98,14 +109,22 @@ const emptyLabel = computed(() => const assignedProjects = computed(() => new Set(roles.value.map(({ project }) => project))) -const availableProjects = computed(() => - core.projects +// An instance or domain admin grant already covers every project: offering a project-specific +// grant on top would be dead data (and reappear as a surprise if the wide role is later +// revoked), so no project entry is offered while the user holds either. +const targetWideGrants = computed(() => roles.value.filter(({ role }) => isInstanceOrDomainRole(role))) +const targetHasWideRole = computed(() => targetWideGrants.value.length > 0) + +const availableProjects = computed(() => { + if (targetHasWideRole.value) return [] + return core.projects .filter(({ name }) => !assignedProjects.value.has(name)) .sort((a, b) => displayLabelOf(a).localeCompare(displayLabelOf(b))) -) +}) const canGrantInstanceRole = computed(() => isInstanceAdmin.value && !roles.value.some(({ role }) => role === ROLE.INSTANCE_ADMIN)) -const canGrantDomainRole = computed(() => isInstanceAdmin.value && !roles.value.some(({ role }) => role === ROLE.DOMAIN_ADMIN)) +// Domain admin is strictly weaker than instance admin, so it's not offered on top of it either. +const canGrantDomainRole = computed(() => isInstanceAdmin.value && !roles.value.some(({ role }) => role === ROLE.DOMAIN_ADMIN || role === ROLE.INSTANCE_ADMIN)) const instanceScopeEntry = computed(() => ({ name: INSTANCE_SCOPE, label: t('settings.users.rolesModal.scope.instance') })) const domainScopeEntry = computed(() => ({ name: DOMAIN_SCOPE, label: t('settings.users.rolesModal.scope.domain') })) @@ -116,6 +135,34 @@ const projectPickerOptions = computed(() => [ ...(isAuthWithUsersProvider.value ? availableProjects.value : []) ]) +// True when this viewer can never offer any scope here, independently of what the target +// holds: not instance admin (so no instance/domain entry) and under OAuth (so no project entry +// either, since a project grant there wouldn't stick past the next login). Checked first since +// it explains an empty picker even for a target with no grants at all, which the other reasons +// below wrongly attribute to the target. +const viewerCanOfferNoScope = computed(() => !isInstanceAdmin.value && !isAuthWithUsersProvider.value) + +// "Revoke it first" is only honest advice when revoking every wide grant held would actually +// unlock a project-specific one: never under OAuth (a project entry is never offered there +// regardless, see projectPickerOptions), and not when one of them is the viewer's own instance +// admin row (canRevoke refuses that one specifically, see below). +const targetWideRoleRevocable = computed(() => + isAuthWithUsersProvider.value && targetHasWideRole.value && targetWideGrants.value.every(grant => canRevoke(grant)) +) + +// Explains the disabled scope picker: the viewer themselves can't offer any scope here, this +// user already holds a role covering every project (revocable or not), or every project already +// has a grant and this viewer cannot offer instance/domain scope. Not shown while merely +// mid-save, since that disablement is unrelated and temporary. +const scopePickerDisabledTitle = computed(() => { + if (saving.value || projectPickerOptions.value.length) return null + if (viewerCanOfferNoScope.value) return t('settings.users.rolesModal.scopePickerDisabledNoViewerScope') + if (!targetHasWideRole.value) return t('settings.users.rolesModal.scopePickerDisabledNoOptions') + return targetWideRoleRevocable.value + ? t('settings.users.rolesModal.scopePickerDisabledWideRole') + : t('settings.users.rolesModal.scopePickerDisabledWideRoleUnrevocable') +}) + function scopeEntry({ role }) { return role === ROLE.DOMAIN_ADMIN ? domainScopeEntry.value : instanceScopeEntry.value } @@ -170,6 +217,14 @@ function toastMessage(key, role, project) { return t(`settings.users.rolesModal.${key}${isProjectScope ? 'OnProject' : ''}`, params) } +function revokeGrant(item) { + if (isInstanceOrDomainRole(item.role)) { + const domain = item.role === ROLE.DOMAIN_ADMIN ? item.domain : null + return core.api.revokeInstanceRole(props.user.uid, ROLE_LOWERCASE[item.role], domain) + } + return core.api.revokeUserRole(props.user.uid, item.project, { ifExists: true }) +} + // Only project rows have a role picker (instance/domain rows show a fixed badge: they're // separate grants, revoke and grant again to switch). grantUserRole overwrites the existing // role for that user/project. @@ -189,17 +244,21 @@ async function changeRole(item, newRole) { } } +// An instance/domain admin grant covers every project: confirm before revoking it, since it's +// not just removing one row but immediately removing access to everything that role covered. +// Plain project-level revokes stay a single click, like everywhere else in this table. async function revokeRole(item) { if (!canRevoke(item)) return + if (isInstanceOrDomainRole(item.role)) { + const description = t('settings.users.rolesModal.revokeWideRoleConfirm', { + role: formatRole(t, item.role), + uid: props.user.uid + }) + if (!(await confirm({ description }))) return + } saving.value = true try { - if (isInstanceOrDomainRole(item.role)) { - const domain = item.role === ROLE.DOMAIN_ADMIN ? item.domain : null - await core.api.revokeInstanceRole(props.user.uid, ROLE_LOWERCASE[item.role], domain) - } - else { - await core.api.revokeUserRole(props.user.uid, item.project, { ifExists: true }) - } + await revokeGrant(item) toast.success(toastMessage('revokeSuccess', item.role, item.project)) emit('user:updated', { uid: props.user.uid }) } @@ -211,8 +270,31 @@ async function revokeRole(item) { } } +// An instance or domain admin role gives access to every project of its scope: any grant the +// user already holds becomes redundant, so granting one of these roles replaces every other grant +// after a confirmation step (see SettingsViewUsersRolesCascadeModal). The backend deletes the +// replaced grants itself on a wide grant: project grants, and domain admin under instance admin. +// TODO #DOMAIN: once multiple domains exist, a domain-admin grant should only cascade-revoke +// grants within that domain, not every grant regardless of domain; harmless today since only one +// domain exists. +const showCascadeModal = ref(false) +const cascadeGrants = ref([]) + async function grantRole() { if (!canGrant.value) return + if ((isInstanceScope.value || isDomainScope.value) && roles.value.length) { + cascadeGrants.value = roles.value + showCascadeModal.value = true + return + } + await performGrant() +} + +function onCascadeConfirm() { + return performGrant() +} + +async function performGrant() { saving.value = true try { if (isInstanceScope.value || isDomainScope.value) { @@ -232,6 +314,7 @@ async function grantRole() { } finally { saving.value = false + cascadeGrants.value = [] } } @@ -243,6 +326,8 @@ defineExpose({ projectPickerOptions, canGrantInstanceRole, canGrantDomainRole, + scopePickerDisabledTitle, + viewerCanOfferNoScope, canRevoke, isInstanceScope, isDomainScope, @@ -251,6 +336,9 @@ defineExpose({ selectedRole, selectedProjectName, canGrant, + showCascadeModal, + cascadeGrants, + onCascadeConfirm, revokeRole, grantRole, changeRole @@ -309,14 +397,25 @@ defineExpose({