[backport cloud/1.37] Workspaces 4 members invites (#8301)

## Summary

Backport of #8245 to cloud/1.37.

Add team workspace member management and invite system.

- Add members panel with role management (owner/admin/member) and member
removal
- Add invite system with email invites, pending invite display, and
revoke functionality
- Add invite URL loading for accepting invites
- Add subscription panel updates for member management
- Add i18n translations for member and invite features

## Conflict Resolution

- `src/components/dialog/GlobalDialog.vue`: Added missing
`DialogPassThroughOptions` import
- `src/locales/en/main.json`: Kept "nightly" section from main (was
present before PR)
- `src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts`:
Deleted (file doesn't exist in cloud/1.37, only contains unrelated
method rename)

(cherry picked from commit 4771565486)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8301-backport-cloud-1-37-Workspaces-4-members-invites-2f36d73d36508119a388dac9d290efbd)
by [Unito](https://www.unito.io)
This commit is contained in:
Simula_r
2026-01-24 19:05:05 -08:00
committed by GitHub
parent 3b7e102b52
commit 1963f28429
32 changed files with 1791 additions and 129 deletions

View File

@@ -0,0 +1,232 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useInviteUrlLoader } from './useInviteUrlLoader'
/**
* Unit tests for useInviteUrlLoader composable
*
* Tests the behavior of accepting workspace invites via URL query parameters:
* - ?invite=TOKEN accepts the invite and shows success toast
* - Invalid/missing token is handled gracefully
* - API errors show error toast
* - URL is cleaned up after processing
* - Preserved query is restored after login redirect
*/
const preservedQueryMocks = vi.hoisted(() => ({
clearPreservedQuery: vi.fn(),
hydratePreservedQuery: vi.fn(),
mergePreservedQueryIntoQuery: vi.fn()
}))
vi.mock(
'@/platform/navigation/preservedQueryManager',
() => preservedQueryMocks
)
const mockRouteQuery = vi.hoisted(() => ({
value: {} as Record<string, string>
}))
const mockRouterReplace = vi.hoisted(() => vi.fn())
vi.mock('vue-router', () => ({
useRoute: () => ({
query: mockRouteQuery.value
}),
useRouter: () => ({
replace: mockRouterReplace
})
}))
const mockToastAdd = vi.hoisted(() => vi.fn())
vi.mock('primevue/usetoast', () => ({
useToast: () => ({
add: mockToastAdd
})
}))
vi.mock('vue-i18n', () => ({
createI18n: () => ({
global: {
t: (key: string) => key
}
}),
useI18n: () => ({
t: vi.fn((key: string, params?: Record<string, unknown>) => {
if (key === 'workspace.inviteAccepted') return 'Invite Accepted'
if (key === 'workspace.addedToWorkspace') {
return `You have been added to ${params?.workspaceName}`
}
if (key === 'workspace.inviteFailed') return 'Failed to Accept Invite'
if (key === 'g.unknownError') return 'Unknown error'
return key
})
})
}))
const mockAcceptInvite = vi.hoisted(() => vi.fn())
vi.mock('../stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
acceptInvite: mockAcceptInvite
})
}))
describe('useInviteUrlLoader', () => {
beforeEach(() => {
vi.clearAllMocks()
mockRouteQuery.value = {}
preservedQueryMocks.mergePreservedQueryIntoQuery.mockReturnValue(null)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('loadInviteFromUrl', () => {
it('does nothing when no invite param present', async () => {
mockRouteQuery.value = {}
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
expect(mockAcceptInvite).not.toHaveBeenCalled()
expect(mockToastAdd).not.toHaveBeenCalled()
expect(mockRouterReplace).not.toHaveBeenCalled()
})
it('restores preserved query and processes invite', async () => {
mockRouteQuery.value = {}
preservedQueryMocks.mergePreservedQueryIntoQuery.mockReturnValue({
invite: 'preserved-token'
})
mockAcceptInvite.mockResolvedValue({
workspaceId: 'ws-123',
workspaceName: 'Test Workspace'
})
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
expect(preservedQueryMocks.hydratePreservedQuery).toHaveBeenCalledWith(
'invite'
)
expect(mockRouterReplace).toHaveBeenCalledWith({
query: { invite: 'preserved-token' }
})
expect(mockAcceptInvite).toHaveBeenCalledWith('preserved-token')
})
it('accepts invite and shows success toast on success', async () => {
mockRouteQuery.value = { invite: 'valid-token' }
mockAcceptInvite.mockResolvedValue({
workspaceId: 'ws-123',
workspaceName: 'Test Workspace'
})
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
expect(mockAcceptInvite).toHaveBeenCalledWith('valid-token')
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'success',
summary: 'Invite Accepted',
detail: 'You have been added to Test Workspace',
life: 5000
})
})
it('shows error toast when invite acceptance fails', async () => {
mockRouteQuery.value = { invite: 'invalid-token' }
mockAcceptInvite.mockRejectedValue(new Error('Invalid invite'))
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
expect(mockAcceptInvite).toHaveBeenCalledWith('invalid-token')
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'Failed to Accept Invite',
detail: 'Invalid invite',
life: 5000
})
})
it('cleans up URL after processing invite', async () => {
mockRouteQuery.value = { invite: 'valid-token', other: 'param' }
mockAcceptInvite.mockResolvedValue({
workspaceId: 'ws-123',
workspaceName: 'Test Workspace'
})
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
// Should replace with query without invite param
expect(mockRouterReplace).toHaveBeenCalledWith({
query: { other: 'param' }
})
})
it('clears preserved query after processing', async () => {
mockRouteQuery.value = { invite: 'valid-token' }
mockAcceptInvite.mockResolvedValue({
workspaceId: 'ws-123',
workspaceName: 'Test Workspace'
})
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
'invite'
)
})
it('clears preserved query even on error', async () => {
mockRouteQuery.value = { invite: 'invalid-token' }
mockAcceptInvite.mockRejectedValue(new Error('Invalid invite'))
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
'invite'
)
})
it('sends any token format to backend for validation', async () => {
mockRouteQuery.value = { invite: 'any-token-format==' }
mockAcceptInvite.mockRejectedValue(new Error('Invalid token'))
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
// Token is sent to backend, which validates and rejects
expect(mockAcceptInvite).toHaveBeenCalledWith('any-token-format==')
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'Failed to Accept Invite',
detail: 'Invalid token',
life: 5000
})
})
it('ignores empty invite param', async () => {
mockRouteQuery.value = { invite: '' }
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
expect(mockAcceptInvite).not.toHaveBeenCalled()
})
it('ignores non-string invite param', async () => {
mockRouteQuery.value = { invite: ['array', 'value'] as unknown as string }
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
expect(mockAcceptInvite).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,107 @@
import { useToast } from 'primevue/usetoast'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import {
clearPreservedQuery,
hydratePreservedQuery,
mergePreservedQueryIntoQuery
} from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useTeamWorkspaceStore } from '../stores/teamWorkspaceStore'
/**
* Composable for loading workspace invites from URL query parameters
*
* Supports URLs like:
* - /?invite=TOKEN (accepts workspace invite)
*
* The invite token is preserved through login redirects via the
* preserved query system (sessionStorage), following the same pattern
* as the template URL loader.
*/
export function useInviteUrlLoader() {
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const toast = useToast()
const workspaceStore = useTeamWorkspaceStore()
const INVITE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.INVITE
/**
* Hydrates preserved query from sessionStorage and merges into route.
* This restores the invite token after login redirects.
*/
const ensureInviteQueryFromIntent = async () => {
hydratePreservedQuery(INVITE_NAMESPACE)
const mergedQuery = mergePreservedQueryIntoQuery(
INVITE_NAMESPACE,
route.query
)
if (mergedQuery) {
await router.replace({ query: mergedQuery })
}
return mergedQuery ?? route.query
}
/**
* Removes invite parameter from URL using Vue Router
*/
const cleanupUrlParams = () => {
const newQuery = { ...route.query }
delete newQuery.invite
void router.replace({ query: newQuery })
}
/**
* Loads and accepts workspace invite from URL query parameters if present.
* Handles errors internally and shows appropriate user feedback.
*
* Flow:
* 1. Restore preserved query (for post-login redirect)
* 2. Check for invite token in route.query
* 3. Accept the invite via API (backend validates token)
* 4. Show toast notification
* 5. Clean up URL and preserved query
*/
const loadInviteFromUrl = async () => {
// Restore preserved query from sessionStorage (handles login redirect case)
const query = await ensureInviteQueryFromIntent()
const inviteParam = query.invite
if (!inviteParam || typeof inviteParam !== 'string') {
return
}
try {
const result = await workspaceStore.acceptInvite(inviteParam)
toast.add({
severity: 'success',
summary: t('workspace.inviteAccepted'),
detail: t(
'workspace.addedToWorkspace',
{ workspaceName: result.workspaceName },
{ escapeParameter: false }
),
life: 5000
})
} catch (error) {
toast.add({
severity: 'error',
summary: t('workspace.inviteFailed'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
})
} finally {
cleanupUrlParams()
clearPreservedQuery(INVITE_NAMESPACE)
}
}
return {
loadInviteFromUrl
}
}

View File

@@ -6,6 +6,11 @@ import { useTeamWorkspaceStore } from '../stores/teamWorkspaceStore'
/** Permission flags for workspace actions */
interface WorkspacePermissions {
canViewOtherMembers: boolean
canViewPendingInvites: boolean
canInviteMembers: boolean
canManageInvites: boolean
canRemoveMembers: boolean
canLeaveWorkspace: boolean
canAccessWorkspaceMenu: boolean
canManageSubscription: boolean
@@ -13,6 +18,14 @@ interface WorkspacePermissions {
/** UI configuration for workspace role */
interface WorkspaceUIConfig {
showMembersList: boolean
showPendingTab: boolean
showSearch: boolean
showDateColumn: boolean
showRoleBadge: boolean
membersGridCols: string
pendingGridCols: string
headerGridCols: string
showEditWorkspaceMenuItem: boolean
workspaceMenuAction: 'leave' | 'delete' | null
workspaceMenuDisabledTooltip: string | null
@@ -24,6 +37,11 @@ function getPermissions(
): WorkspacePermissions {
if (type === 'personal') {
return {
canViewOtherMembers: false,
canViewPendingInvites: false,
canInviteMembers: false,
canManageInvites: false,
canRemoveMembers: false,
canLeaveWorkspace: false,
canAccessWorkspaceMenu: false,
canManageSubscription: true
@@ -32,6 +50,11 @@ function getPermissions(
if (role === 'owner') {
return {
canViewOtherMembers: true,
canViewPendingInvites: true,
canInviteMembers: true,
canManageInvites: true,
canRemoveMembers: true,
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: true
@@ -40,6 +63,11 @@ function getPermissions(
// member role
return {
canViewOtherMembers: true,
canViewPendingInvites: false,
canInviteMembers: false,
canManageInvites: false,
canRemoveMembers: false,
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: false
@@ -52,6 +80,14 @@ function getUIConfig(
): WorkspaceUIConfig {
if (type === 'personal') {
return {
showMembersList: false,
showPendingTab: false,
showSearch: false,
showDateColumn: false,
showRoleBadge: false,
membersGridCols: 'grid-cols-1',
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
headerGridCols: 'grid-cols-1',
showEditWorkspaceMenuItem: false,
workspaceMenuAction: null,
workspaceMenuDisabledTooltip: null
@@ -60,6 +96,14 @@ function getUIConfig(
if (role === 'owner') {
return {
showMembersList: true,
showPendingTab: true,
showSearch: true,
showDateColumn: true,
showRoleBadge: true,
membersGridCols: 'grid-cols-[50%_40%_10%]',
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
headerGridCols: 'grid-cols-[50%_40%_10%]',
showEditWorkspaceMenuItem: true,
workspaceMenuAction: 'delete',
workspaceMenuDisabledTooltip:
@@ -69,6 +113,14 @@ function getUIConfig(
// member role
return {
showMembersList: true,
showPendingTab: false,
showSearch: true,
showDateColumn: true,
showRoleBadge: true,
membersGridCols: 'grid-cols-[1fr_auto]',
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
headerGridCols: 'grid-cols-[1fr_auto]',
showEditWorkspaceMenuItem: false,
workspaceMenuAction: 'leave',
workspaceMenuDisabledTooltip: null