mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-11 01:28:03 +00:00
## Summary Adds an in-app deep link that opens the pricing table directly, for driving pilot users straight to subscribe (request from nav/Alex). Resolves [FE-1104](https://linear.app/comfyorg/issue/FE-1104). - `/?pricing=1` — on app load, open the pricing table. - `/?pricing=team` / `/?pricing=personal` — open it on the Team / Personal plan tab (via the existing `UnifiedPricingTable` `initialPlanMode`). - Gated to the **original owner** via `useWorkspaceUI().permissions.canManageSubscriptionLifecycle` (personal user, or a team workspace's original owner). A member or a promoted owner is a **silent no-op**: the app loads normally, the param is stripped, no 404 / error / toast. - Off-cloud (OSS): the loader isn't instantiated, so the param is ignored. - Survives the login redirect via the preserved-query system, same as `?invite` / `?create_workspace`. ## How Mirrors the established URL-loader pattern (`useInviteUrlLoader` / `useCreateWorkspaceUrlLoader`): - `preservedQueryNamespaces.ts` / `router.ts` — register the `pricing` namespace + tracker key. - New `usePricingTableUrlLoader.ts` — hydrate preserved query, read `pricing`, strip the param + `clearPreservedQuery` in a single replace before any await, then `await fetchMembers()` (resolves the original-owner gate; no-ops for personal) and open the table only when the gate allows. - `GraphCanvas.vue` — call the loader in `onMounted` after the create-workspace loader (cloud only; not gated on the team-workspaces flag so it also drives personal/legacy users). - `useSubscriptionDialog.ts` — new `'deep_link'` value on `SubscriptionDialogReason`. ## Telemetry Eligible opens emit the existing `subscription_required_modal_opened` PostHog event with the new `reason: 'deep_link'`. Ineligible-click bounce rate is derivable from the autocaptured pageview URL (`?pricing=…`), so no new event plumbing. ## Stacking / dependencies This feature needs two sibling stacks off `main`: - **FE-934** (`#12666`, base of this PR) — the `UnifiedPricingTable` + `showPricingTable({ planMode })`. - **FE-770** (`#12829`) — the `canManageSubscriptionLifecycle` gate. **Merged into this branch**, so the diff against the FE-934 base includes FE-770's changes until it lands. Review the single `feat(billing): deep link…` commit. Once both land on `main`, rebase onto `main` and the diff collapses to just this feature. Do not merge before FE-770 and FE-934. Post-Billing-V1 follow-up. End-state: swap the FE original-owner heuristic for the BE workspace-level `is_original_owner` flag when it lands (removes the members-fetch). ## Tests - Unit (`usePricingTableUrlLoader.test.ts`, 12 cases): opens for an original owner; `team`/`personal` tab preselect; silent no-op + param-strip for a member/promoted owner; proves the gate is read only after `fetchMembers` resolves; preserved-query restore; empty/non-string/absent/unrecognized param; members-fetch failure strips+clears without opening. - E2E (`browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts`, `@cloud`, 4 cases, verified locally): personal owner opens + URL stripped; `?pricing=team` lands on the active Team tab; team original owner opens (real `is_original_owner` + email gate); team member is a silent no-op + URL stripped. - Typecheck + related unit suites (`useSubscriptionDialog`, `useWorkspaceUI`, `teamWorkspaceStore`) green. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: GitHub Action <action@github.com> Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com> Co-authored-by: github-actions <github-actions@github.com>
255 lines
7.1 KiB
TypeScript
255 lines
7.1 KiB
TypeScript
import { until } from '@vueuse/core'
|
|
import { storeToRefs } from 'pinia'
|
|
import {
|
|
createRouter,
|
|
createWebHashHistory,
|
|
createWebHistory
|
|
} from 'vue-router'
|
|
import type { RouteLocationNormalized } from 'vue-router'
|
|
|
|
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
|
import { isCloud, isDesktop } from '@/platform/distribution/types'
|
|
import { useTelemetry } from '@/platform/telemetry'
|
|
import { useDialogService } from '@/services/dialogService'
|
|
import { useAuthStore } from '@/stores/authStore'
|
|
import { useUserStore } from '@/stores/userStore'
|
|
import LayoutDefault from '@/views/layouts/LayoutDefault.vue'
|
|
|
|
import { captureOAuthRequestId } from '@/platform/cloud/oauth/oauthState'
|
|
import { installPreservedQueryTracker } from '@/platform/navigation/preservedQueryTracker'
|
|
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
|
import { preserveLoggedOutShareAuthAttribution } from '@/platform/workflow/sharing/utils/shareAuthAttribution'
|
|
|
|
const cloudOnboardingRoutes = isCloud
|
|
? (await import('./platform/cloud/onboarding/onboardingCloudRoutes'))
|
|
.cloudOnboardingRoutes
|
|
: []
|
|
|
|
const isFileProtocol = window.location.protocol === 'file:'
|
|
|
|
/**
|
|
* Determine base path for the router.
|
|
* - Electron: always root
|
|
* - Cloud: use Vite's BASE_URL (configured at build time)
|
|
* - Standard web (including reverse proxy subpaths): use window.location.pathname
|
|
* to support deployments like http://mysite.com/ComfyUI/
|
|
*/
|
|
function getBasePath(): string {
|
|
if (isDesktop) return '/'
|
|
if (isCloud) return import.meta.env?.BASE_URL || '/'
|
|
return window.location.pathname
|
|
}
|
|
|
|
const basePath = getBasePath()
|
|
|
|
function trackPageView(): void {
|
|
useTelemetry()?.trackPageView(document.title, {
|
|
path: window.location.href
|
|
})
|
|
}
|
|
|
|
const router = createRouter({
|
|
history: isFileProtocol
|
|
? createWebHashHistory()
|
|
: // Base path must be specified to ensure correct relative paths
|
|
// Example: For URL 'http://localhost:7801/ComfyBackendDirect',
|
|
// we need this base path or assets will incorrectly resolve from 'http://localhost:7801/'
|
|
createWebHistory(basePath),
|
|
routes: [
|
|
...(isCloud ? cloudOnboardingRoutes : []),
|
|
{
|
|
path: '/',
|
|
component: LayoutDefault,
|
|
children: [
|
|
{
|
|
path: '',
|
|
name: 'GraphView',
|
|
component: () => import('@/views/GraphView.vue'),
|
|
beforeEnter: async (_to, _from, next) => {
|
|
// Then check user store
|
|
const userStore = useUserStore()
|
|
await userStore.initialize()
|
|
if (userStore.needsLogin) {
|
|
next('/user-select')
|
|
} else {
|
|
next()
|
|
}
|
|
}
|
|
},
|
|
{
|
|
path: 'user-select',
|
|
name: 'UserSelectView',
|
|
component: () => import('@/views/UserSelectView.vue')
|
|
}
|
|
]
|
|
}
|
|
],
|
|
|
|
scrollBehavior(_to, _from, savedPosition) {
|
|
if (savedPosition) {
|
|
return savedPosition
|
|
} else {
|
|
return { top: 0 }
|
|
}
|
|
}
|
|
})
|
|
|
|
installPreservedQueryTracker(router, [
|
|
{
|
|
namespace: PRESERVED_QUERY_NAMESPACES.TEMPLATE,
|
|
keys: ['template', 'source', 'mode']
|
|
},
|
|
{
|
|
namespace: PRESERVED_QUERY_NAMESPACES.SHARE,
|
|
keys: ['share']
|
|
},
|
|
{
|
|
namespace: PRESERVED_QUERY_NAMESPACES.INVITE,
|
|
keys: ['invite']
|
|
},
|
|
{
|
|
namespace: PRESERVED_QUERY_NAMESPACES.CREATE_WORKSPACE,
|
|
keys: ['create_workspace']
|
|
},
|
|
{
|
|
namespace: PRESERVED_QUERY_NAMESPACES.OAUTH,
|
|
keys: ['oauth_request_id']
|
|
},
|
|
{
|
|
namespace: PRESERVED_QUERY_NAMESPACES.PRICING,
|
|
keys: ['pricing']
|
|
}
|
|
])
|
|
|
|
router.beforeEach((to, _from, next) => {
|
|
captureOAuthRequestId(to.query)
|
|
next()
|
|
})
|
|
|
|
router.afterEach(() => {
|
|
trackPageView()
|
|
})
|
|
|
|
if (isCloud) {
|
|
const { flags } = useFeatureFlags()
|
|
const PUBLIC_ROUTE_NAMES = new Set([
|
|
'cloud-login',
|
|
'cloud-signup',
|
|
'cloud-forgot-password',
|
|
'cloud-oauth-consent',
|
|
'cloud-sorry-contact-support'
|
|
])
|
|
const PUBLIC_ROUTE_PATHS = new Set([
|
|
'/cloud/login',
|
|
'/cloud/signup',
|
|
'/cloud/forgot-password',
|
|
'/cloud/oauth/consent',
|
|
'/cloud/sorry-contact-support'
|
|
])
|
|
|
|
function isPublicRoute(to: RouteLocationNormalized) {
|
|
const name = String(to.name)
|
|
if (PUBLIC_ROUTE_NAMES.has(name)) return true
|
|
const path = to.path
|
|
return PUBLIC_ROUTE_PATHS.has(path)
|
|
}
|
|
// Global authentication guard
|
|
router.beforeEach(async (to, _from, next) => {
|
|
const authStore = useAuthStore()
|
|
|
|
// Wait for Firebase auth to initialize
|
|
// Timeout after 16 seconds
|
|
if (!authStore.isInitialized) {
|
|
try {
|
|
const { isInitialized } = storeToRefs(authStore)
|
|
await until(isInitialized).toBe(true, { timeout: 16_000 })
|
|
} catch (error) {
|
|
console.error('Auth initialization failed:', error)
|
|
return next({ name: 'cloud-auth-timeout' })
|
|
}
|
|
}
|
|
|
|
// Pass authenticated users
|
|
const authHeader = await authStore.getAuthHeader()
|
|
const isLoggedIn = !!authHeader
|
|
preserveLoggedOutShareAuthAttribution(to.query, isLoggedIn)
|
|
|
|
// Allow public routes
|
|
if (isPublicRoute(to)) {
|
|
return next()
|
|
}
|
|
|
|
// Special handling for user-check
|
|
// These routes need auth but handle their own routing logic
|
|
if (to.name === 'cloud-user-check') {
|
|
if (to.meta.requiresAuth && !isLoggedIn) {
|
|
return next({ name: 'cloud-login' })
|
|
}
|
|
return next()
|
|
}
|
|
|
|
// Prevent redirect loop when coming from user-check
|
|
if (_from.name === 'cloud-user-check' && to.path === '/') {
|
|
return next()
|
|
}
|
|
|
|
const query =
|
|
to.fullPath === '/'
|
|
? undefined
|
|
: { previousFullPath: encodeURIComponent(to.fullPath) }
|
|
|
|
// Check if route requires authentication
|
|
if (to.meta.requiresAuth && !isLoggedIn) {
|
|
return next({
|
|
name: 'cloud-login',
|
|
query
|
|
})
|
|
}
|
|
|
|
// Handle other protected routes
|
|
if (!isLoggedIn) {
|
|
// For Electron, use dialog
|
|
if (isDesktop) {
|
|
const dialogService = useDialogService()
|
|
const loginSuccess = await dialogService.showSignInDialog()
|
|
return loginSuccess ? next() : next(false)
|
|
}
|
|
|
|
// For web, redirect to login
|
|
return next({
|
|
name: 'cloud-login',
|
|
query
|
|
})
|
|
}
|
|
|
|
// User is logged in - check if they need onboarding (when enabled)
|
|
// For root path, check actual user status to handle waitlisted users
|
|
if (!isDesktop && isLoggedIn && to.path === '/') {
|
|
if (!flags.onboardingSurveyEnabled) {
|
|
return next()
|
|
}
|
|
// Import auth functions dynamically to avoid circular dependency
|
|
const { getSurveyCompletedStatus } =
|
|
await import('@/platform/cloud/onboarding/auth')
|
|
try {
|
|
// Check user's actual status
|
|
const surveyCompleted = await getSurveyCompletedStatus()
|
|
|
|
// Survey is required for all users (when feature flag enabled)
|
|
if (!surveyCompleted) {
|
|
return next({ name: 'cloud-survey' })
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to check user status:', error)
|
|
// On error, redirect to user-check as fallback
|
|
return next({ name: 'cloud-user-check' })
|
|
}
|
|
}
|
|
|
|
// User is logged in and accessing protected route
|
|
return next()
|
|
})
|
|
}
|
|
|
|
export default router
|