feat: invite and members working

This commit is contained in:
--list
2026-01-19 15:30:20 -08:00
parent bc698fb746
commit d6bdf4feff
11 changed files with 605 additions and 233 deletions

View File

@@ -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()
})
})
})

View File

@@ -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
}
}