+
+
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({