feat: add change role dialog for workspace members

- Add ChangeRoleDialogContent with radio selection between Owner/Member
- Add 'Change role' option to member snowman menu in MembersPanelContent
- Add updateMemberRole API method and store action
- Remove duplicate 'Delete Workspace' from snowman menu (now in Settings tab)
- Add i18n translations for change role dialog
This commit is contained in:
Hunter Senft-Grupp
2026-03-05 17:01:51 -05:00
parent 6af10fca06
commit 3a4d9d341e
7 changed files with 232 additions and 11 deletions

View File

@@ -2424,6 +2424,7 @@
"actions": {
"copyLink": "Copy invite link",
"revokeInvite": "Revoke invite",
"changeRole": "Change role",
"removeMember": "Remove member"
},
"upsellBannerSubscribe": "Subscribe to the Creator plan or above to invite team members to this workspace.",
@@ -2474,6 +2475,22 @@
"transfer": "Transfer ownership",
"noEligibleMembers": "No eligible members to transfer ownership to. Invite a member first."
},
"changeRoleDialog": {
"title": "Change the role of {name}?",
"selectNewRole": "Select a new role",
"changeRole": "Change role",
"error": "Failed to change role",
"roles": {
"owner": {
"label": "Owner",
"description": "Has full administrative access to the entire workspace."
},
"member": {
"label": "Member",
"description": "Can use workspace credits and view other members in the workspace."
}
}
},
"dangerZone": {
"title": "Danger zone",
"transferOwnership": {

View File

@@ -460,6 +460,23 @@ export const workspaceApi = {
}
},
/**
* Update a member's role in the workspace.
* PATCH /api/workspace/members/:userId/role
*/
async updateMemberRole(userId: string, role: WorkspaceRole): Promise<void> {
const headers = await getAuthHeaderOrThrow()
try {
await workspaceApiClient.patch(
api.apiURL(`/workspace/members/${userId}/role`),
{ role },
{ headers }
)
} catch (err) {
handleAxiosError(err)
}
},
/**
* List pending invites for the workspace.
* GET /api/workspace/invites

View File

@@ -0,0 +1,144 @@
<template>
<div
class="flex w-full max-w-[360px] flex-col rounded-2xl border border-border-default bg-base-background"
>
<!-- Header -->
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h2 class="m-0 text-sm font-normal text-base-foreground">
{{
$t('workspacePanel.changeRoleDialog.title', {
name: memberName
})
}}
</h2>
<button
class="cursor-pointer rounded-sm border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
:aria-label="$t('g.close')"
@click="onCancel"
>
<i class="pi pi-times size-4" />
</button>
</div>
<!-- Body -->
<div class="px-4 py-4">
<p class="m-0 mb-4 text-sm text-muted-foreground">
{{ $t('workspacePanel.changeRoleDialog.selectNewRole') }}
</p>
<div role="radiogroup" class="flex flex-col gap-2">
<label
v-for="option in roleOptions"
:key="option.value"
:class="
cn(
'flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors',
selectedRole === option.value
? 'border-secondary-foreground bg-secondary-background/50'
: 'border-border-default hover:bg-secondary-background/30'
)
"
>
<input
type="radio"
name="workspace-role"
:value="option.value"
:checked="selectedRole === option.value"
class="mt-0.5 size-4 shrink-0 cursor-pointer accent-secondary-foreground"
@change="selectedRole = option.value"
/>
<div class="flex flex-col gap-0.5">
<span class="text-sm font-medium text-base-foreground">
{{ option.label }}
</span>
<span class="text-xs text-muted-foreground">
{{ option.description }}
</span>
</div>
</label>
</div>
</div>
<!-- Footer -->
<div
class="flex items-center justify-end gap-4 border-t border-border-default px-4 py-4"
>
<Button variant="muted-textonly" @click="onCancel">
{{ $t('g.cancel') }}
</Button>
<Button
variant="primary"
size="lg"
:loading
:disabled="selectedRole === currentRole"
@click="onChangeRole"
>
{{ $t('workspacePanel.changeRoleDialog.changeRole') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { WorkspaceRole } from '@/platform/workspace/api/workspaceApi'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@/utils/tailwindUtil'
const { memberId, memberName, currentRole } = defineProps<{
memberId: string
memberName: string
currentRole: WorkspaceRole
}>()
const { t } = useI18n()
const toast = useToast()
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
const loading = ref(false)
const selectedRole = ref<WorkspaceRole>(currentRole)
const roleOptions = [
{
value: 'owner' as WorkspaceRole,
label: t('workspacePanel.changeRoleDialog.roles.owner.label'),
description: t('workspacePanel.changeRoleDialog.roles.owner.description')
},
{
value: 'member' as WorkspaceRole,
label: t('workspacePanel.changeRoleDialog.roles.member.label'),
description: t('workspacePanel.changeRoleDialog.roles.member.description')
}
]
function onCancel() {
dialogStore.closeDialog({ key: 'change-role' })
}
async function onChangeRole() {
if (selectedRole.value === currentRole) return
loading.value = true
try {
await workspaceStore.updateMemberRole(memberId, selectedRole.value)
dialogStore.closeDialog({ key: 'change-role' })
} catch (error) {
console.error('[ChangeRoleDialog] Failed to change role:', error)
toast.add({
severity: 'error',
summary: t('workspacePanel.changeRoleDialog.error'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
})
} finally {
loading.value = false
}
}
</script>

View File

@@ -386,6 +386,7 @@ const { d, t } = useI18n()
const toast = useToast()
const { userPhotoUrl, userEmail, userDisplayName } = useCurrentUser()
const {
showChangeRoleDialog,
showRemoveMemberDialog,
showRevokeInviteDialog,
showCreateWorkspaceDialog
@@ -437,6 +438,15 @@ function getInviteInitial(email: string): string {
}
const memberMenuItems = computed(() => [
{
label: t('workspacePanel.members.actions.changeRole'),
icon: 'pi pi-users',
command: () => {
if (selectedMember.value) {
handleChangeRole(selectedMember.value)
}
}
},
{
label: t('workspacePanel.members.actions.removeMember'),
icon: 'pi pi-user-minus',
@@ -557,6 +567,14 @@ function handleCreateWorkspace() {
showCreateWorkspaceDialog()
}
function handleChangeRole(member: WorkspaceMember) {
showChangeRoleDialog({
memberId: member.id,
memberName: member.name,
currentRole: member.role
})
}
function handleRemoveMember(member: WorkspaceMember) {
showRemoveMemberDialog(member.id)
}

View File

@@ -313,17 +313,7 @@ const menuItems = computed(() => {
}
const action = uiConfig.value.workspaceMenuAction
if (action === 'delete') {
items.push({
label: t('workspacePanel.menu.deleteWorkspace'),
icon: 'pi pi-trash',
class: isDeleteDisabled.value
? 'text-danger/50 cursor-not-allowed'
: 'text-danger',
disabled: isDeleteDisabled.value,
command: isDeleteDisabled.value ? undefined : handleDeleteWorkspace
})
} else if (action === 'leave') {
if (action === 'leave') {
items.push({
label: t('workspacePanel.menu.leaveWorkspace'),
icon: 'pi pi-sign-out',

View File

@@ -529,6 +529,24 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
return members
}
/**
* Update a member's role in the current workspace.
*/
async function updateMemberRole(
userId: string,
role: 'owner' | 'member'
): Promise<void> {
await workspaceApi.updateMemberRole(userId, role)
const current = activeWorkspace.value
if (current) {
updateActiveWorkspace({
members: current.members.map((m) =>
m.id === userId ? { ...m, role } : m
)
})
}
}
/**
* Remove a member from the current workspace.
*/
@@ -698,6 +716,7 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
// Member Actions
fetchMembers,
updateMemberRole,
removeMember,
// Invite Actions

View File

@@ -484,6 +484,21 @@ export const useDialogService = () => {
})
}
async function showChangeRoleDialog(options: {
memberId: string
memberName: string
currentRole: 'owner' | 'member'
}) {
const { default: component } =
await import('@/platform/workspace/components/dialogs/ChangeRoleDialogContent.vue')
return dialogStore.showDialog({
key: 'change-role',
component,
props: options,
dialogComponentProps: workspaceDialogPt
})
}
async function showRemoveMemberDialog(memberId: string) {
const { default: component } =
await import('@/platform/workspace/components/dialogs/RemoveMemberDialogContent.vue')
@@ -579,6 +594,7 @@ export const useDialogService = () => {
showCreateWorkspaceDialog,
showLeaveWorkspaceDialog,
showEditWorkspaceDialog,
showChangeRoleDialog,
showRemoveMemberDialog,
showRevokeInviteDialog,
showInviteMemberDialog,