mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-05 05:00:03 +00:00
feat: invite and members working
This commit is contained in:
@@ -10,10 +10,13 @@ import { useInviteUrlLoader } from './useInviteUrlLoader'
|
||||
* - 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()
|
||||
clearPreservedQuery: vi.fn(),
|
||||
hydratePreservedQuery: vi.fn(),
|
||||
mergePreservedQueryIntoQuery: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
@@ -21,15 +24,27 @@ vi.mock(
|
||||
() => preservedQueryMocks
|
||||
)
|
||||
|
||||
// Mock toast store
|
||||
const mockToastAdd = vi.fn()
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({
|
||||
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
|
||||
})
|
||||
}))
|
||||
|
||||
// Mock i18n
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: vi.fn((key: string, params?: Record<string, unknown>) => {
|
||||
@@ -38,132 +53,73 @@ vi.mock('vue-i18n', () => ({
|
||||
return `You have been added to ${params?.workspaceName}`
|
||||
}
|
||||
if (key === 'workspace.inviteFailed') return 'Failed to Accept Invite'
|
||||
if (key === 'g.error') return 'Error'
|
||||
if (key === 'g.unknownError') return 'Unknown error'
|
||||
return key
|
||||
})
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useInviteUrlLoader', () => {
|
||||
const mockReplaceState = vi.fn()
|
||||
const mockLocation = {
|
||||
search: '',
|
||||
href: 'https://cloud.comfy.org/',
|
||||
origin: 'https://cloud.comfy.org'
|
||||
}
|
||||
const mockAcceptInvite = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../stores/workspaceStore', () => ({
|
||||
useWorkspaceStore: () => ({
|
||||
acceptInvite: mockAcceptInvite
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useInviteUrlLoader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockLocation.search = ''
|
||||
mockLocation.href = 'https://cloud.comfy.org/'
|
||||
|
||||
// Mock location using vi.stubGlobal
|
||||
vi.stubGlobal('location', mockLocation)
|
||||
|
||||
// Mock history.replaceState
|
||||
vi.spyOn(window.history, 'replaceState').mockImplementation(
|
||||
mockReplaceState
|
||||
)
|
||||
mockRouteQuery.value = {}
|
||||
preservedQueryMocks.mergePreservedQueryIntoQuery.mockReturnValue(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('getInviteTokenFromUrl', () => {
|
||||
it('returns null when no invite param present', () => {
|
||||
window.location.search = ''
|
||||
|
||||
const { getInviteTokenFromUrl } = useInviteUrlLoader()
|
||||
const token = getInviteTokenFromUrl()
|
||||
|
||||
expect(token).toBeNull()
|
||||
})
|
||||
|
||||
it('returns token when invite param is present', () => {
|
||||
window.location.search = '?invite=test-token-123'
|
||||
|
||||
const { getInviteTokenFromUrl } = useInviteUrlLoader()
|
||||
const token = getInviteTokenFromUrl()
|
||||
|
||||
expect(token).toBe('test-token-123')
|
||||
})
|
||||
|
||||
it('returns null for empty invite param', () => {
|
||||
window.location.search = '?invite='
|
||||
|
||||
const { getInviteTokenFromUrl } = useInviteUrlLoader()
|
||||
const token = getInviteTokenFromUrl()
|
||||
|
||||
expect(token).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for whitespace-only invite param', () => {
|
||||
window.location.search = '?invite=%20%20'
|
||||
|
||||
const { getInviteTokenFromUrl } = useInviteUrlLoader()
|
||||
const token = getInviteTokenFromUrl()
|
||||
|
||||
expect(token).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearInviteTokenFromUrl', () => {
|
||||
it('removes invite param from URL', () => {
|
||||
window.location.search = '?invite=test-token'
|
||||
window.location.href = 'https://cloud.comfy.org/?invite=test-token'
|
||||
|
||||
const { clearInviteTokenFromUrl } = useInviteUrlLoader()
|
||||
clearInviteTokenFromUrl()
|
||||
|
||||
expect(mockReplaceState).toHaveBeenCalledWith(
|
||||
window.history.state,
|
||||
'',
|
||||
'https://cloud.comfy.org/'
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves other query params when removing invite', () => {
|
||||
window.location.search = '?invite=test-token&other=param'
|
||||
window.location.href =
|
||||
'https://cloud.comfy.org/?invite=test-token&other=param'
|
||||
|
||||
const { clearInviteTokenFromUrl } = useInviteUrlLoader()
|
||||
clearInviteTokenFromUrl()
|
||||
|
||||
expect(mockReplaceState).toHaveBeenCalledWith(
|
||||
window.history.state,
|
||||
'',
|
||||
'https://cloud.comfy.org/?other=param'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadInviteFromUrl', () => {
|
||||
it('does nothing when no invite param present', async () => {
|
||||
window.location.search = ''
|
||||
mockRouteQuery.value = {}
|
||||
|
||||
const mockAcceptInvite = vi.fn()
|
||||
const { loadInviteFromUrl } = useInviteUrlLoader()
|
||||
await loadInviteFromUrl(mockAcceptInvite)
|
||||
await loadInviteFromUrl()
|
||||
|
||||
expect(mockAcceptInvite).not.toHaveBeenCalled()
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
expect(mockReplaceState).not.toHaveBeenCalled()
|
||||
expect(mockRouterReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts invite and shows success toast on success', async () => {
|
||||
window.location.search = '?invite=valid-token'
|
||||
window.location.href = 'https://cloud.comfy.org/?invite=valid-token'
|
||||
|
||||
const mockAcceptInvite = vi.fn().mockResolvedValue({
|
||||
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(mockAcceptInvite)
|
||||
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({
|
||||
@@ -172,64 +128,100 @@ describe('useInviteUrlLoader', () => {
|
||||
detail: 'You have been added to Test Workspace',
|
||||
life: 5000
|
||||
})
|
||||
expect(mockReplaceState).toHaveBeenCalled()
|
||||
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
|
||||
'invite'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows error toast when invite acceptance fails', async () => {
|
||||
window.location.search = '?invite=invalid-token'
|
||||
window.location.href = 'https://cloud.comfy.org/?invite=invalid-token'
|
||||
|
||||
const mockAcceptInvite = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Invalid invite'))
|
||||
mockRouteQuery.value = { invite: 'invalid-token' }
|
||||
mockAcceptInvite.mockRejectedValue(new Error('Invalid invite'))
|
||||
|
||||
const { loadInviteFromUrl } = useInviteUrlLoader()
|
||||
await loadInviteFromUrl(mockAcceptInvite)
|
||||
await loadInviteFromUrl()
|
||||
|
||||
expect(mockAcceptInvite).toHaveBeenCalledWith('invalid-token')
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'Failed to Accept Invite',
|
||||
detail: 'Error',
|
||||
detail: 'Invalid invite',
|
||||
life: 5000
|
||||
})
|
||||
})
|
||||
|
||||
it('cleans up URL even on error', async () => {
|
||||
window.location.search = '?invite=invalid-token'
|
||||
window.location.href = 'https://cloud.comfy.org/?invite=invalid-token'
|
||||
|
||||
const mockAcceptInvite = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Invalid invite'))
|
||||
|
||||
const { loadInviteFromUrl } = useInviteUrlLoader()
|
||||
await loadInviteFromUrl(mockAcceptInvite)
|
||||
|
||||
expect(mockReplaceState).toHaveBeenCalled()
|
||||
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
|
||||
'invite'
|
||||
)
|
||||
})
|
||||
|
||||
it('clears preserved query on success', async () => {
|
||||
window.location.search = '?invite=valid-token'
|
||||
window.location.href = 'https://cloud.comfy.org/?invite=valid-token'
|
||||
|
||||
const mockAcceptInvite = vi.fn().mockResolvedValue({
|
||||
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(mockAcceptInvite)
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
|
||||
import {
|
||||
clearPreservedQuery,
|
||||
hydratePreservedQuery,
|
||||
mergePreservedQueryIntoQuery
|
||||
} from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
type AcceptInviteFn = (
|
||||
token: string
|
||||
) => Promise<{ workspaceId: string; workspaceName: string }>
|
||||
import { useWorkspaceStore } from '../stores/workspaceStore'
|
||||
|
||||
const LOG_PREFIX = '[useInviteUrlLoader]'
|
||||
|
||||
/**
|
||||
* Composable for loading workspace invites from URL query parameters
|
||||
@@ -14,47 +19,85 @@ type AcceptInviteFn = (
|
||||
* Supports URLs like:
|
||||
* - /?invite=TOKEN (accepts workspace invite)
|
||||
*
|
||||
* Input validation:
|
||||
* - Token parameter must be a non-empty string
|
||||
* 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 toastStore = useToastStore()
|
||||
const toast = useToast()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const INVITE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.INVITE
|
||||
|
||||
/**
|
||||
* Gets the invite token from URL query parameters
|
||||
* Hydrates preserved query from sessionStorage and merges into route.
|
||||
* This restores the invite token after login redirects.
|
||||
*/
|
||||
function getInviteTokenFromUrl(): string | null {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const token = params.get('invite')
|
||||
return token && token.trim().length > 0 ? token : null
|
||||
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 the invite parameter from URL without triggering navigation
|
||||
* Removes invite parameter from URL using Vue Router
|
||||
*/
|
||||
function clearInviteTokenFromUrl() {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete('invite')
|
||||
window.history.replaceState(window.history.state, '', url.toString())
|
||||
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
|
||||
* 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
|
||||
*/
|
||||
async function loadInviteFromUrl(acceptInvite: AcceptInviteFn) {
|
||||
const token = getInviteTokenFromUrl()
|
||||
const loadInviteFromUrl = async () => {
|
||||
console.log(LOG_PREFIX, 'Starting invite URL loading')
|
||||
console.log(LOG_PREFIX, 'Current route.query:', route.query)
|
||||
|
||||
if (!token) {
|
||||
// Restore preserved query from sessionStorage (handles login redirect case)
|
||||
const query = await ensureInviteQueryFromIntent()
|
||||
console.log(LOG_PREFIX, 'Query after hydration:', query)
|
||||
|
||||
const inviteParam = query.invite
|
||||
console.log(
|
||||
LOG_PREFIX,
|
||||
'Invite param:',
|
||||
inviteParam,
|
||||
'type:',
|
||||
typeof inviteParam
|
||||
)
|
||||
|
||||
if (!inviteParam || typeof inviteParam !== 'string') {
|
||||
console.log(LOG_PREFIX, 'No valid invite param found, skipping')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await acceptInvite(token)
|
||||
console.log(LOG_PREFIX, 'Accepting invite with token:', inviteParam)
|
||||
|
||||
toastStore.add({
|
||||
try {
|
||||
const result = await workspaceStore.acceptInvite(inviteParam)
|
||||
console.log(LOG_PREFIX, 'Invite accepted successfully:', result)
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('workspace.inviteAccepted'),
|
||||
detail: t('workspace.addedToWorkspace', {
|
||||
@@ -63,22 +106,20 @@ export function useInviteUrlLoader() {
|
||||
life: 5000
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[useInviteUrlLoader] Failed to accept invite:', error)
|
||||
toastStore.add({
|
||||
console.error(LOG_PREFIX, 'Failed to accept invite:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspace.inviteFailed'),
|
||||
detail: t('g.error'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
clearInviteTokenFromUrl()
|
||||
cleanupUrlParams()
|
||||
clearPreservedQuery(INVITE_NAMESPACE)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getInviteTokenFromUrl,
|
||||
clearInviteTokenFromUrl,
|
||||
loadInviteFromUrl
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user