Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
11 changes: 11 additions & 0 deletions src/components/DropdownSelector/DropdownSelector.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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"
Expand Down
1 change: 0 additions & 1 deletion src/components/Project/ProjectRow/ProjectRowUserRole.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ const userRole = computed(() => getRoleByProject(props.project.name))
<display-role
:value="userRole"
:project="project"
no-icon
/>
</slot>
</td>
Expand Down
71 changes: 21 additions & 50 deletions src/components/ProjectUsers/ProjectUsersList.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<script setup>
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { AppIcon, ButtonIcon } from '@icij/murmur'
import IPhCaretRight from '~icons/ph/caret-right'
import { ButtonIcon } from '@icij/murmur'

import DisplayUser from '@/components/Display/DisplayUser.vue'
import InstanceUsersRoleBadge from '@/components/InstanceUsers/InstanceUsersRoleBadge.vue'
import PageTableGeneric from '@/components/PageTable/PageTableGeneric.vue'
import ProjectUsersActions from '@/components/ProjectUsers/ProjectUsersActions.vue'
import ProjectUsersAdminPromotionModal from '@/components/ProjectUsers/ProjectUsersAdminPromotionModal.vue'
Expand All @@ -13,7 +13,7 @@ import ProjectUsersRoleDropdown from '@/components/ProjectUsers/ProjectUsersRole
import { useCore } from '@/composables/useCore.js'
import { usePolicies } from '@/composables/usePolicies.js'
import { useToast } from '@/composables/useToast.js'
import { NO_ROLE, ROLE, ROLE_BIT, ROLE_LOWERCASE, roleColor, roleIcon } from '@/enums/roles.js'
import { NO_ROLE, ROLE, ROLE_BIT, ROLE_LOWERCASE } from '@/enums/roles.js'
import ButtonReset from '@/components/Button/ButtonReset'
import useAuth from '@/composables/useAuth.js'

Expand Down Expand Up @@ -135,7 +135,7 @@ const emptyLabel = computed(() =>
// value of a user listed without one. Only offered when datashare owns the accounts (form/basic):
// under OAuth, project roles come from the identity provider and a revoke would not stick.
const { isCurrentUser, isAuthWithUsersProvider } = useAuth()
const { getRoleByProject, hasRole, isDomainAdmin, formatRole } = usePolicies()
const { getRoleByProject, hasRole, isDomainAdmin } = usePolicies()
const viewerRole = computed(() => getRoleByProject(props.project))
// The viewer's own row is read-only: a project admin dropping their own role would lose access to
// this tab with nobody able to undo it. A domain or instance admin keeps access through their
Expand Down Expand Up @@ -171,41 +171,23 @@ defineExpose({ pendingChanges, saving, showAdminModal, saveRoles, cancelChanges,
<display-user :value="item.uid" />
</template>
<template #cell(role)="{ item }">
<!-- Widest scope first: each instance or domain admin role (which gives access to every
project of its scope, whatever the dropdown shows) as an icon, then the project role. -->
<div class="d-flex align-items-center flex-nowrap gap-1">
<template
v-for="wideRole in item.wideRoles ?? []"
:key="wideRole"
>
<span
v-b-tooltip.body
class="project-users-list__wide-role"
:title="formatRole(t, wideRole)"
:aria-label="formatRole(t, wideRole)"
>
<app-icon
:name="roleIcon(wideRole)"
:style="{ color: roleColor(wideRole) }"
/>
</span>
<app-icon
:name="IPhCaretRight"
class="project-users-list__wide-role-separator text-secondary"
aria-hidden="true"
/>
</template>
<project-users-role-dropdown
:disabled="isLockedOwnRow(item.uid) || outranksViewer(item.role)"
:model-value="pendingChanges[item.uid] ?? item.role"
:dirty="!!pendingChanges[item.uid]"
:project="project"
:no-role="isAuthWithUsersProvider"
:hidden-roles="[ROLE.DOMAIN_ADMIN, ROLE.INSTANCE_ADMIN]"
:inherited="!!item.wideRoles?.length"
@update:model-value="onRoleChanged(item.uid, $event)"
/>
</div>
<!-- An instance or domain admin role gives access to every project of its scope, making
any project-specific grant redundant: show only that highest rank, read-only, rather
than a dropdown that can't actually change the user's access to this project. -->
<instance-users-role-badge
v-if="item.wideRoles?.length"
:role="item.wideRoles[0]"
/>
<project-users-role-dropdown
v-else
:disabled="isLockedOwnRow(item.uid) || outranksViewer(item.role)"
:model-value="pendingChanges[item.uid] ?? item.role"
:dirty="!!pendingChanges[item.uid]"
:project="project"
:no-role="isAuthWithUsersProvider"
:hidden-roles="[ROLE.DOMAIN_ADMIN, ROLE.INSTANCE_ADMIN]"
@update:model-value="onRoleChanged(item.uid, $event)"
/>
</template>
<template #row-actions="{ item }">
<project-users-actions :user="item" />
Expand Down Expand Up @@ -243,17 +225,6 @@ defineExpose({ pendingChanges, saving, showAdminModal, saveRoles, cancelChanges,

<style scoped lang="scss">
.project-users-list {
&__wide-role {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75em;
height: 1.75em;
flex-shrink: 0;
border-radius: 50%;
background: var(--bs-tertiary-bg);
}

&__sticky-bar {
position: sticky;
bottom: 0;
Expand Down
13 changes: 0 additions & 13 deletions src/components/ProjectUsers/ProjectUsersRoleDropdown.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,6 @@ const props = defineProps({
hiddenRoles: {
type: Array,
default: () => []
},
// Shows "Inherited" instead of "No role" when the user has no role of their own but gets access
// through an instance or domain admin role.
inherited: {
type: Boolean,
default: false
}
})

Expand Down Expand Up @@ -79,14 +73,7 @@ defineExpose({ availableRoles })
>
<template #button-content>
<div class="project-users-role-dropdown__content d-flex justify-content-between ">
<span
v-if="inherited && modelValue === NO_ROLE"
class="project-users-role-dropdown__inherited text-secondary"
>
{{ t('role.inherited') }}
</span>
<display-role
v-else
:value="modelValue"
/><app-icon
v-if="dirty"
Expand Down
8 changes: 8 additions & 0 deletions src/components/RowPagination/RowPagination.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const hasFewerRows = computed(() => props.totalRows <= page.value * +props.perPa
:per-page="+perPage"
:compact="compact"
class="row-pagination"
:class="{ 'row-pagination--empty': !totalRows }"
>
<template #number-of-rows="{ lastRangeRow: to }">
<i18n-t
Expand Down Expand Up @@ -86,5 +87,12 @@ const hasFewerRows = computed(() => props.totalRows <= page.value * +props.perPa
&:deep(.tiny-pagination__nav .app-icon) {
font-size: 1.25em;
}

// With zero rows, the (disabled) row-number input still shows a literal "0" next to the
// "of 0 ..." label, reading as "0 of 0 users" - hide the now-meaningless input, keeping just
// the label.
&--empty:deep(.tiny-pagination__form__input--row) {
display: none;
}
}
</style>
31 changes: 23 additions & 8 deletions src/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -295,8 +295,7 @@
"project_editor": "Editor",
"project_member": "Member",
"project_visitor": "Visitor",
"no_role": "No role",
"inherited": "Inherited"
"no_role": "No role"
},
"searchFormControl": {
"submitLabel": "Search"
Expand Down Expand Up @@ -1739,7 +1738,8 @@
},
"email": {
"label": "Email",
"placeholder": "Email"
"placeholder": "Email",
"invalid": "Enter a valid email address."
},
"password": {
"label": "Password",
Expand All @@ -1756,9 +1756,12 @@
"title": "Delete user {name}?",
"body": {
"intro": "This action cannot be undone. Deleting this account will:",
"rolesRevoked": "<strong>remove</strong> all of their role grants (project, domain and instance);",
"dataDeleted": "<strong>delete</strong> all their personal data (stars, history, tags, saved searches etc.) across all projects;",
"tasksKept": "<strong>keep</strong> their tasks and task results."
"rolesRevoked": "{verb} all of their role grants (project, domain and instance);",
"rolesRevokedVerb": "remove",
"dataDeleted": "{verb} all their personal data (stars, history, tags, saved searches etc.) across all projects;",
"dataDeletedVerb": "delete",
"tasksKept": "{verb} their tasks and task results.",
"tasksKeptVerb": "keep"
},
"confirm": "Delete user",
"success": "User deleted successfully",
Expand All @@ -1768,25 +1771,37 @@
"rolesModal": {
"title": "Manage roles for {uid}",
"close": "Close",
"empty": "This user has no project role grants yet.",
"empty": "This user has no role grants yet.",
"searchPlaceholder": "Search scopes",
"noResults": "No project or role matches your search.",
"selectScope": "Select scope",
"scopePickerDisabledWideRole": "This user already has a role covering every project. Revoke it first to grant a project-specific role.",
"scopePickerDisabledWideRoleUnrevocable": "This user already has the widest role available here.",
"scopePickerDisabledNoOptions": "This user already has a role on every available project.",
"scopePickerDisabledNoViewerScope": "You need instance admin rights to grant an instance-wide or domain-wide role here, and project-specific roles aren't available under this authentication mode.",
"scopeColumn": "Scope",
"roleColumn": "Role",
"scope": {
"instance": "Instance",
"domain": "Domain"
},
"grant": "Grant",
"revokeWideRoleConfirm": "Revoke {role} from {uid}? This immediately removes their access to every project it covered.",
"revokeSuccess": "Revoked {role} from {uid}",
"revokeError": "Failed to revoke {role} from {uid}.",
"grantSuccess": "Granted {role} to {uid}",
"grantError": "Failed to grant {role} to {uid}.",
"revokeSuccessOnProject": "Revoked {role} on {project} from {uid}",
"revokeErrorOnProject": "Failed to revoke {role} on {project} from {uid}.",
"grantSuccessOnProject": "Granted {role} on {project} to {uid}",
"grantErrorOnProject": "Failed to grant {role} on {project} to {uid}."
"grantErrorOnProject": "Failed to grant {role} on {project} to {uid}.",
"cascadeModal": {
"title": "Replace existing grants?",
"body": "This role already covers every project, so these existing grants will be revoked. They will not come back if this role is revoked later:",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

important: under OAuth, the roles modal's own comment says project grants come back from the identity provider at each login, so "They will not come back if this role is revoked later" is not true there. Could grantRole leave project grants out of cascadeGrants when not isAuthWithUsersProvider, or pass a flag so the cascade modal drops that sentence?

@caro3801 caro3801 Oct 7, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a5ef038. The cascade confirmation was making a permanence promise that only holds for datashare-native grants. It now checks isAuthWithUsersProvider and uses a different sentence under OAuth, where a project grant can come back on its own at the next login regardless of this role.

"bodyMayReturn": "This role already covers every project, so these existing grants will be revoked. Some may come back on their own, reconciled from your identity provider at the next login, independently of this role:",
"confirm": "Grant and revoke the rest",
"cancel": "Cancel"
}
}
},
"snapshots": {
Expand Down
16 changes: 16 additions & 0 deletions src/views/Settings/SettingsView/SettingsViewUsersCreateModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ const email = ref('')
const name = ref('')
const { password, confirmPassword, passwordMismatch, isPasswordValid, clearPasswords } = usePasswordConfirm()
const saving = ref(false)
// Set on a 409 from the server; cleared as soon as the user edits the username again, since the
// stale conflict no longer necessarily applies to whatever they're about to submit.
const usernameConflict = ref(false)
watch(username, () => {
usernameConflict.value = false
})

const isValid = computed(() => {
if (!username.value.trim().length) return false
Expand All @@ -42,6 +48,7 @@ function resetForm() {
username.value = ''
email.value = ''
name.value = ''
usernameConflict.value = false
clearPasswords()
}

Expand Down Expand Up @@ -76,6 +83,7 @@ async function saveUser(bvModalEvent) {
catch (err) {
const status = err?.response?.status ?? err?.request?.response?.status
if (status === 409) {
usernameConflict.value = true
toast.error(t('settings.users.create.saveErrorConflict'))
}
else {
Expand All @@ -95,6 +103,7 @@ defineExpose({
confirmPassword,
isValid,
passwordMismatch,
usernameConflict,
saving,
saveUser,
form
Expand Down Expand Up @@ -126,9 +135,16 @@ defineExpose({
v-model="username"
:placeholder="t('settings.users.create.fields.username.placeholder')"
:disabled="saving"
:state="usernameConflict ? false : null"
autofocus
name="uid"
/>
<small
v-if="usernameConflict"
class="text-danger"
>
{{ t('settings.users.create.saveErrorConflict') }}
</small>
</form-fieldset-i18n>

<form-fieldset-i18n
Expand Down
27 changes: 24 additions & 3 deletions src/views/Settings/SettingsView/SettingsViewUsersDeleteModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,30 @@ defineExpose({ confirmDeletion })
{{ t('settings.users.deleteModal.body.intro') }}
</p>
<ul v-if="!notFound">
<li v-html="t('settings.users.deleteModal.body.rolesRevoked')" />
<li v-html="t('settings.users.deleteModal.body.dataDeleted')" />
<li v-html="t('settings.users.deleteModal.body.tasksKept')" />
<i18n-t
keypath="settings.users.deleteModal.body.rolesRevoked"
tag="li"
>
<template #verb>
<strong>{{ t('settings.users.deleteModal.body.rolesRevokedVerb') }}</strong>
</template>
</i18n-t>
<i18n-t
keypath="settings.users.deleteModal.body.dataDeleted"
tag="li"
>
<template #verb>
<strong>{{ t('settings.users.deleteModal.body.dataDeletedVerb') }}</strong>
</template>
</i18n-t>
<i18n-t
keypath="settings.users.deleteModal.body.tasksKept"
tag="li"
>
<template #verb>
<strong>{{ t('settings.users.deleteModal.body.tasksKeptVerb') }}</strong>
</template>
</i18n-t>
</ul>
</app-modal>
</template>
Loading
Loading