feat: add workspace transfer ownership and settings tab with danger zone

- Add Settings tab to workspace panel (owner-only, team workspaces)
- Add Danger Zone section with Transfer Ownership and Delete Workspace
- Add TransferOwnershipDialogContent with member selector
- Add transferOwnership API method and store action
- Add canTransferOwnership permission to useWorkspaceUI
- Add showTransferOwnershipDialog to dialogService
- Add i18n translations for all new UI
This commit is contained in:
Hunter Senft-Grupp
2026-03-05 14:28:13 -05:00
parent 25d8384716
commit 6af10fca06
7 changed files with 291 additions and 2 deletions

View File

@@ -2403,7 +2403,8 @@
"tabs": {
"dashboard": "Dashboard",
"planCredits": "Plan & Credits",
"membersCount": "Members ({count})"
"membersCount": "Members ({count})",
"settings": "Settings"
},
"dashboard": {
"placeholder": "Dashboard workspace settings"
@@ -2466,6 +2467,26 @@
"message": "This member won't be able to join your workspace anymore. Their invite link will be invalidated.",
"revoke": "Uninvite"
},
"transferOwnershipDialog": {
"title": "Transfer ownership",
"message": "Select a member to become the new owner. You will become a regular member.",
"selectMember": "New owner",
"transfer": "Transfer ownership",
"noEligibleMembers": "No eligible members to transfer ownership to. Invite a member first."
},
"dangerZone": {
"title": "Danger zone",
"transferOwnership": {
"title": "Transfer ownership",
"description": "Transfer this workspace to another member. You will become a regular member.",
"button": "Transfer"
},
"deleteWorkspace": {
"title": "Delete this workspace",
"description": "Permanently delete this workspace and all associated data. This cannot be undone.",
"button": "Delete"
}
},
"inviteUpsellDialog": {
"titleNotSubscribed": "A subscription is required to invite members",
"titleSingleSeat": "Your current plan supports a single seat",
@@ -2517,7 +2538,12 @@
"failedToCreateWorkspace": "Failed to create workspace",
"failedToDeleteWorkspace": "Failed to delete workspace",
"failedToLeaveWorkspace": "Failed to leave workspace",
"failedToFetchWorkspaces": "Failed to load workspaces"
"failedToFetchWorkspaces": "Failed to load workspaces",
"ownershipTransferred": {
"title": "Ownership transferred",
"message": "You are now a member of this workspace."
},
"failedToTransferOwnership": "Failed to transfer ownership"
}
},
"workspaceSwitcher": {

View File

@@ -443,6 +443,23 @@ export const workspaceApi = {
}
},
/**
* Transfer workspace ownership to another member.
* POST /api/workspace/transfer-ownership
*/
async transferOwnership(targetUserId: string): Promise<void> {
const headers = await getAuthHeaderOrThrow()
try {
await workspaceApiClient.post(
api.apiURL('/workspace/transfer-ownership'),
{ target_user_id: targetUserId },
{ headers }
)
} catch (err) {
handleAxiosError(err)
}
},
/**
* List pending invites for the workspace.
* GET /api/workspace/invites

View File

@@ -0,0 +1,120 @@
<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.transferOwnershipDialog.title') }}
</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.transferOwnershipDialog.message') }}
</p>
<template v-if="eligibleMembers.length > 0">
<label class="mb-1 block text-xs font-medium text-muted-foreground">
{{ $t('workspacePanel.transferOwnershipDialog.selectMember') }}
</label>
<select
v-model="selectedMemberId"
class="w-full rounded-lg border border-border-default bg-base-background px-3 py-2 text-sm text-base-foreground outline-none focus:ring-1 focus:ring-secondary-foreground"
>
<option value="" disabled></option>
<option
v-for="member in eligibleMembers"
:key="member.id"
:value="member.id"
>
{{ member.name }} ({{ member.email }})
</option>
</select>
</template>
<p v-else class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.transferOwnershipDialog.noEligibleMembers') }}
</p>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" @click="onCancel">
{{ $t('g.cancel') }}
</Button>
<Button
variant="destructive"
size="lg"
:loading
:disabled="!selectedMemberId || eligibleMembers.length === 0"
@click="onTransfer"
>
{{ $t('workspacePanel.transferOwnershipDialog.transfer') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const { t } = useI18n()
const toast = useToast()
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
const { userEmail } = useCurrentUser()
const loading = ref(false)
const selectedMemberId = ref('')
const eligibleMembers = computed(() =>
workspaceStore.members.filter(
(m) =>
m.role !== 'owner' &&
m.email.toLowerCase() !== userEmail.value?.toLowerCase()
)
)
function onCancel() {
dialogStore.closeDialog({ key: 'transfer-ownership' })
}
async function onTransfer() {
if (!selectedMemberId.value) return
loading.value = true
try {
await workspaceStore.transferOwnership(selectedMemberId.value)
dialogStore.closeDialog({ key: 'transfer-ownership' })
} catch (error) {
console.error(
'[TransferOwnershipDialog] Failed to transfer ownership:',
error
)
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToTransferOwnership'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
})
} finally {
loading.value = false
}
}
</script>

View File

@@ -38,6 +38,18 @@
})
}}
</TabsTrigger>
<TabsTrigger
v-if="showSettingsTab"
value="settings"
:class="
cn(
tabTriggerBase,
activeTab === 'settings' ? tabTriggerActive : tabTriggerInactive
)
"
>
{{ $t('workspacePanel.tabs.settings') }}
</TabsTrigger>
</TabsList>
<div class="flex items-center gap-1">
<Button
@@ -109,6 +121,75 @@
<TabsContent value="members" class="mt-4">
<MembersPanelContent :key="workspaceRole" />
</TabsContent>
<TabsContent v-if="showSettingsTab" value="settings" class="mt-4">
<div class="flex flex-col gap-6">
<div class="rounded-2xl border border-danger/30 p-6">
<h3 class="m-0 mb-4 text-base font-semibold text-danger">
{{ $t('workspacePanel.dangerZone.title') }}
</h3>
<div class="flex flex-col gap-4">
<!-- Transfer Ownership -->
<div
class="flex items-center justify-between gap-4 rounded-lg border border-border-default p-4"
>
<div class="flex flex-col gap-1">
<span class="text-sm font-medium text-base-foreground">
{{
$t('workspacePanel.dangerZone.transferOwnership.title')
}}
</span>
<span class="text-xs text-muted-foreground">
{{
$t(
'workspacePanel.dangerZone.transferOwnership.description'
)
}}
</span>
</div>
<Button
variant="secondary"
size="md"
class="shrink-0 border border-danger bg-transparent text-danger hover:bg-danger/10"
@click="showTransferOwnershipDialog()"
>
{{ $t('workspacePanel.dangerZone.transferOwnership.button') }}
</Button>
</div>
<!-- Delete Workspace -->
<div
class="flex items-center justify-between gap-4 rounded-lg border border-border-default p-4"
>
<div class="flex flex-col gap-1">
<span class="text-sm font-medium text-base-foreground">
{{ $t('workspacePanel.dangerZone.deleteWorkspace.title') }}
</span>
<span class="text-xs text-muted-foreground">
{{
$t(
'workspacePanel.dangerZone.deleteWorkspace.description'
)
}}
</span>
</div>
<Button
v-tooltip="
isDeleteDisabled && deleteTooltip
? { value: deleteTooltip, showDelay: 0 }
: undefined
"
variant="destructive"
size="md"
class="shrink-0"
:disabled="isDeleteDisabled"
@click="handleDeleteWorkspace"
>
{{ $t('workspacePanel.dangerZone.deleteWorkspace.button') }}
</Button>
</div>
</div>
</div>
</div>
</TabsContent>
</TabsRoot>
</div>
</template>
@@ -147,6 +228,7 @@ const { t } = useI18n()
const {
showLeaveWorkspaceDialog,
showDeleteWorkspaceDialog,
showTransferOwnershipDialog,
showInviteMemberDialog,
showInviteMemberUpsellDialog,
showEditWorkspaceDialog
@@ -169,6 +251,12 @@ const { fetchMembers, fetchPendingInvites } = workspaceStore
const { workspaceRole, permissions, uiConfig } = useWorkspaceUI()
const activeTab = ref(defaultTab)
const showSettingsTab = computed(
() =>
workspaceStore.activeWorkspace?.type === 'team' &&
workspaceRole.value === 'owner'
)
const menu = ref<InstanceType<typeof Menu> | null>(null)
function handleLeaveWorkspace() {

View File

@@ -13,6 +13,7 @@ interface WorkspacePermissions {
canRemoveMembers: boolean
canLeaveWorkspace: boolean
canAccessWorkspaceMenu: boolean
canTransferOwnership: boolean
canManageSubscription: boolean
canTopUp: boolean
}
@@ -45,6 +46,7 @@ function getPermissions(
canRemoveMembers: false,
canLeaveWorkspace: false,
canAccessWorkspaceMenu: false,
canTransferOwnership: false,
canManageSubscription: true,
canTopUp: true
}
@@ -59,6 +61,7 @@ function getPermissions(
canRemoveMembers: true,
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canTransferOwnership: true,
canManageSubscription: true,
canTopUp: true
}
@@ -73,6 +76,7 @@ function getPermissions(
canRemoveMembers: false,
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canTransferOwnership: false,
canManageSubscription: false,
canTopUp: false
}

View File

@@ -492,6 +492,28 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
// Code after this won't run (page reloads)
}
/**
* Transfer ownership of the current workspace to another member.
* After transfer, the page reloads to reflect the role change.
*/
async function transferOwnership(targetUserId: string): Promise<void> {
const current = activeWorkspace.value
if (!current || current.type === 'personal') {
throw new Error('Cannot transfer ownership of personal workspace')
}
const workspaceAuthStore = useWorkspaceAuthStore()
await workspaceApi.transferOwnership(targetUserId)
// Reload to get fresh token with updated role
workspaceAuthStore.clearWorkspaceContext()
if (current.id) {
setLastWorkspaceId(current.id)
}
window.location.reload()
}
/**
* Fetch members for the current workspace.
*/
@@ -672,6 +694,7 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
renameWorkspace,
updateWorkspaceName,
leaveWorkspace,
transferOwnership,
// Member Actions
fetchMembers,

View File

@@ -452,6 +452,16 @@ export const useDialogService = () => {
})
}
async function showTransferOwnershipDialog() {
const { default: component } =
await import('@/platform/workspace/components/dialogs/TransferOwnershipDialogContent.vue')
return dialogStore.showDialog({
key: 'transfer-ownership',
component,
dialogComponentProps: workspaceDialogPt
})
}
async function showLeaveWorkspaceDialog() {
const { default: component } =
await import('@/platform/workspace/components/dialogs/LeaveWorkspaceDialogContent.vue')
@@ -565,6 +575,7 @@ export const useDialogService = () => {
showLayoutDialog,
showSmallLayoutDialog,
showDeleteWorkspaceDialog,
showTransferOwnershipDialog,
showCreateWorkspaceDialog,
showLeaveWorkspaceDialog,
showEditWorkspaceDialog,