mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-20 23:04:06 +00:00
Workspaces 4 members invites (#8245)
## Summary Add team workspace member management and invite system. ## Changes - 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 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8245-Workspaces-4-members-invites-2f06d73d36508176b2caf852a1505c4a) by [Unito](https://www.unito.io) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
@@ -26,14 +26,16 @@ vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
const mockRemoteConfig = vi.hoisted(() => ({
|
||||
value: {
|
||||
team_workspaces_enabled: true
|
||||
}
|
||||
}))
|
||||
const mockTeamWorkspacesEnabled = vi.hoisted(() => ({ value: true }))
|
||||
|
||||
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
|
||||
remoteConfig: mockRemoteConfig
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get teamWorkspacesEnabled() {
|
||||
return mockTeamWorkspacesEnabled.value
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const mockWorkspace = {
|
||||
@@ -622,11 +624,11 @@ describe('useWorkspaceAuthStore', () => {
|
||||
|
||||
describe('feature flag disabled', () => {
|
||||
beforeEach(() => {
|
||||
mockRemoteConfig.value.team_workspaces_enabled = false
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockRemoteConfig.value.team_workspaces_enabled = true
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
})
|
||||
|
||||
it('initializeFromSession returns false when flag disabled', () => {
|
||||
|
||||
@@ -14,7 +14,7 @@ const mockSubscriptionTier = ref<
|
||||
const mockIsYearlySubscription = ref(false)
|
||||
const mockAccessBillingPortal = vi.fn()
|
||||
const mockReportError = vi.fn()
|
||||
const mockGetAuthHeader = vi.fn(() =>
|
||||
const mockGetFirebaseAuthHeader = vi.fn(() =>
|
||||
Promise.resolve({ Authorization: 'Bearer test-token' })
|
||||
)
|
||||
|
||||
@@ -53,7 +53,7 @@ vi.mock('@/composables/useErrorHandling', () => ({
|
||||
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: () => ({
|
||||
getAuthHeader: mockGetAuthHeader
|
||||
getFirebaseAuthHeader: mockGetFirebaseAuthHeader
|
||||
}),
|
||||
FirebaseAuthStoreError: class extends Error {}
|
||||
}))
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import { computed, defineAsyncComponent } from 'vue'
|
||||
|
||||
import CloudBadge from '@/components/topbar/CloudBadge.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
@@ -85,7 +85,9 @@ const SubscriptionPanelContentWorkspace = defineAsyncComponent(
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const teamWorkspacesEnabled = isCloud && flags.teamWorkspacesEnabled
|
||||
const teamWorkspacesEnabled = computed(
|
||||
() => isCloud && flags.teamWorkspacesEnabled
|
||||
)
|
||||
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<template>
|
||||
<div class="grow overflow-auto">
|
||||
<div class="grow overflow-auto pt-6">
|
||||
<div class="rounded-2xl border border-interface-stroke p-6">
|
||||
<div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div
|
||||
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between md:gap-2"
|
||||
>
|
||||
<!-- OWNER Unsubscribed State -->
|
||||
<template v-if="isOwnerUnsubscribed">
|
||||
<template v-if="showSubscribePrompt">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm font-bold text-text-primary">
|
||||
{{ $t('subscription.workspaceNotSubscribed') }}
|
||||
@@ -15,6 +17,7 @@
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
class="ml-auto rounded-lg px-4 py-2 text-sm font-normal"
|
||||
@click="handleSubscribeWorkspace"
|
||||
>
|
||||
@@ -65,12 +68,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template
|
||||
<div
|
||||
v-if="isActiveSubscription && permissions.canManageSubscription"
|
||||
class="flex flex-wrap gap-2 md:ml-auto"
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
class="ml-auto rounded-lg px-4 py-2 text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
|
||||
class="rounded-lg px-4 text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
|
||||
@click="
|
||||
async () => {
|
||||
await authActions.accessBillingPortal()
|
||||
@@ -80,23 +85,24 @@
|
||||
{{ $t('subscription.managePayment') }}
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="primary"
|
||||
class="rounded-lg px-4 py-2 text-sm font-normal text-text-primary"
|
||||
class="rounded-lg px-4 text-sm font-normal text-text-primary"
|
||||
@click="showSubscriptionDialog"
|
||||
>
|
||||
{{ $t('subscription.upgradePlan') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
@click="planMenu?.toggle($event)"
|
||||
>
|
||||
<i class="pi pi-ellipsis-h" />
|
||||
</Button>
|
||||
<Menu ref="planMenu" :model="planMenuItems" :popup="true" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -247,6 +253,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
|
||||
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||
@@ -264,26 +271,34 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const { isWorkspaceSubscribed } = storeToRefs(workspaceStore)
|
||||
const { isWorkspaceSubscribed, isInPersonalWorkspace } =
|
||||
storeToRefs(workspaceStore)
|
||||
const { subscribeWorkspace } = workspaceStore
|
||||
const { permissions, workspaceRole } = useWorkspaceUI()
|
||||
const { t, n } = useI18n()
|
||||
const { showBillingComingSoonDialog } = useDialogService()
|
||||
|
||||
// OWNER with unsubscribed workspace - can see subscribe button
|
||||
const isOwnerUnsubscribed = computed(
|
||||
() => workspaceRole.value === 'owner' && !isWorkspaceSubscribed.value
|
||||
)
|
||||
// Show subscribe prompt to owners without active subscription
|
||||
const showSubscribePrompt = computed(() => {
|
||||
if (workspaceRole.value !== 'owner') return false
|
||||
if (isInPersonalWorkspace.value) return !isActiveSubscription.value
|
||||
return !isWorkspaceSubscribed.value
|
||||
})
|
||||
|
||||
// MEMBER view - members can't manage subscription, show read-only zero state
|
||||
const isMemberView = computed(() => !permissions.value.canManageSubscription)
|
||||
|
||||
// Show zero state for credits (no real billing data yet)
|
||||
const showZeroState = computed(
|
||||
() => isOwnerUnsubscribed.value || isMemberView.value
|
||||
() => showSubscribePrompt.value || isMemberView.value
|
||||
)
|
||||
|
||||
// Demo: Subscribe workspace to PRO monthly plan
|
||||
// Subscribe workspace - show billing coming soon dialog for team workspaces
|
||||
function handleSubscribeWorkspace() {
|
||||
if (!isInPersonalWorkspace.value) {
|
||||
showBillingComingSoonDialog()
|
||||
return
|
||||
}
|
||||
subscribeWorkspace('PRO_MONTHLY')
|
||||
}
|
||||
|
||||
|
||||
@@ -35,8 +35,8 @@ export async function performSubscriptionCheckout(
|
||||
): Promise<void> {
|
||||
if (!isCloud) return
|
||||
|
||||
const { getAuthHeader } = useFirebaseAuthStore()
|
||||
const authHeader = await getAuthHeader()
|
||||
const { getFirebaseAuthHeader } = useFirebaseAuthStore()
|
||||
const authHeader = await getFirebaseAuthHeader()
|
||||
|
||||
if (!authHeader) {
|
||||
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
|
||||
|
||||
@@ -56,7 +56,9 @@ const writeToStorage = (
|
||||
}
|
||||
|
||||
export const hydratePreservedQuery = (namespace: string) => {
|
||||
if (preservedQueries.has(namespace)) return
|
||||
if (preservedQueries.has(namespace)) {
|
||||
return
|
||||
}
|
||||
const payload = readFromStorage(namespace)
|
||||
if (payload) {
|
||||
preservedQueries.set(namespace, payload)
|
||||
@@ -77,7 +79,9 @@ export const capturePreservedQuery = (
|
||||
}
|
||||
})
|
||||
|
||||
if (Object.keys(payload).length === 0) return
|
||||
if (Object.keys(payload).length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
preservedQueries.set(namespace, payload)
|
||||
writeToStorage(namespace, payload)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export const PRESERVED_QUERY_NAMESPACES = {
|
||||
TEMPLATE: 'template'
|
||||
TEMPLATE: 'template',
|
||||
INVITE: 'invite'
|
||||
} as const
|
||||
|
||||
@@ -2,25 +2,27 @@
|
||||
<div
|
||||
:class="
|
||||
teamWorkspacesEnabled
|
||||
? 'flex h-[80vh] w-full overflow-hidden'
|
||||
? 'flex h-full w-full overflow-auto flex-col md:flex-row'
|
||||
: 'settings-container'
|
||||
"
|
||||
>
|
||||
<ScrollPanel
|
||||
:class="
|
||||
teamWorkspacesEnabled
|
||||
? 'w-48 shrink-0 p-2 2xl:w-64'
|
||||
? 'w-full md:w-64 md:min-w-64 md:max-w-64 shrink-0 p-2'
|
||||
: 'settings-sidebar w-48 shrink-0 p-2 2xl:w-64'
|
||||
"
|
||||
>
|
||||
<SearchBox
|
||||
v-model:model-value="searchQuery"
|
||||
class="settings-search-box mb-2 w-full"
|
||||
:placeholder="$t('g.searchSettings') + '...'"
|
||||
:debounce-time="128"
|
||||
autofocus
|
||||
@search="handleSearch"
|
||||
/>
|
||||
<div :class="teamWorkspacesEnabled ? 'px-4' : ''">
|
||||
<SearchBox
|
||||
v-model:model-value="searchQuery"
|
||||
class="settings-search-box mb-2 w-full"
|
||||
:placeholder="$t('g.searchSettings') + '...'"
|
||||
:debounce-time="128"
|
||||
autofocus
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<Listbox
|
||||
v-model="activeCategory"
|
||||
:options="groupedMenuTreeNodes"
|
||||
@@ -62,7 +64,7 @@
|
||||
:lazy="true"
|
||||
:class="
|
||||
teamWorkspacesEnabled
|
||||
? 'h-full flex-1 overflow-x-auto'
|
||||
? 'h-full flex-1 overflow-auto scrollbar-custom'
|
||||
: 'settings-content h-full w-full'
|
||||
"
|
||||
>
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface Member {
|
||||
name: string
|
||||
email: string
|
||||
joined_at: string
|
||||
role: WorkspaceRole
|
||||
}
|
||||
|
||||
interface PaginationInfo {
|
||||
@@ -110,6 +111,18 @@ async function getAuthHeaderOrThrow() {
|
||||
return authHeader
|
||||
}
|
||||
|
||||
async function getFirebaseHeaderOrThrow() {
|
||||
const authHeader = await useFirebaseAuthStore().getFirebaseAuthHeader()
|
||||
if (!authHeader) {
|
||||
throw new WorkspaceApiError(
|
||||
t('toastMessages.userNotAuthenticated'),
|
||||
401,
|
||||
'NOT_AUTHENTICATED'
|
||||
)
|
||||
}
|
||||
return authHeader
|
||||
}
|
||||
|
||||
function handleAxiosError(err: unknown): never {
|
||||
if (axios.isAxiosError(err)) {
|
||||
const status = err.response?.status
|
||||
@@ -296,9 +309,10 @@ export const workspaceApi = {
|
||||
/**
|
||||
* Accept a workspace invite.
|
||||
* POST /api/invites/:token/accept
|
||||
* Uses Firebase auth (user identity) since the user isn't yet a workspace member.
|
||||
*/
|
||||
async acceptInvite(token: string): Promise<AcceptInviteResponse> {
|
||||
const headers = await getAuthHeaderOrThrow()
|
||||
const headers = await getFirebaseHeaderOrThrow()
|
||||
try {
|
||||
const response = await workspaceApiClient.post<AcceptInviteResponse>(
|
||||
api.apiURL(`/invites/${token}/accept`),
|
||||
|
||||
232
src/platform/workspace/composables/useInviteUrlLoader.test.ts
Normal file
232
src/platform/workspace/composables/useInviteUrlLoader.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
107
src/platform/workspace/composables/useInviteUrlLoader.ts
Normal file
107
src/platform/workspace/composables/useInviteUrlLoader.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,8 @@ import { defineStore } from 'pinia'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
|
||||
import { WORKSPACE_STORAGE_KEYS } from '@/platform/auth/workspace/workspaceConstants'
|
||||
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { useWorkspaceAuthStore } from '@/stores/workspaceAuthStore'
|
||||
|
||||
import type {
|
||||
@@ -12,14 +14,15 @@ import type {
|
||||
} from '../api/workspaceApi'
|
||||
import { workspaceApi } from '../api/workspaceApi'
|
||||
|
||||
interface WorkspaceMember {
|
||||
export interface WorkspaceMember {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
joinDate: Date
|
||||
role: 'owner' | 'member'
|
||||
}
|
||||
|
||||
interface PendingInvite {
|
||||
export interface PendingInvite {
|
||||
id: string
|
||||
email: string
|
||||
token: string
|
||||
@@ -43,7 +46,8 @@ function mapApiMemberToWorkspaceMember(member: Member): WorkspaceMember {
|
||||
id: member.id,
|
||||
name: member.name,
|
||||
email: member.email,
|
||||
joinDate: new Date(member.joined_at)
|
||||
joinDate: new Date(member.joined_at),
|
||||
role: member.role
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +64,8 @@ function mapApiInviteToPendingInvite(invite: ApiPendingInvite): PendingInvite {
|
||||
function createWorkspaceState(workspace: WorkspaceWithRole): WorkspaceState {
|
||||
return {
|
||||
...workspace,
|
||||
isSubscribed: false,
|
||||
// Personal workspaces use user-scoped subscription from useSubscription()
|
||||
isSubscribed: workspace.type === 'personal',
|
||||
subscriptionPlan: null,
|
||||
members: [],
|
||||
pendingInvites: []
|
||||
@@ -367,6 +372,9 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
|
||||
// Clear context and switch to new workspace
|
||||
workspaceAuthStore.clearWorkspaceContext()
|
||||
// Clear any preserved invite query to prevent stale invites from being
|
||||
// processed after the reload (prevents owner adding themselves as member)
|
||||
clearPreservedQuery(PRESERVED_QUERY_NAMESPACES.INVITE)
|
||||
setLastWorkspaceId(newWorkspace.id)
|
||||
window.location.reload()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user