diff --git a/browser_tests/fixtures/helpers/WorkflowHelper.ts b/browser_tests/fixtures/helpers/WorkflowHelper.ts index cdb40dd306d..cc82451df03 100644 --- a/browser_tests/fixtures/helpers/WorkflowHelper.ts +++ b/browser_tests/fixtures/helpers/WorkflowHelper.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'fs' import { test } from '@playwright/test' -import type { AppMode } from '@/composables/useAppMode' +import type { AppMode } from '@/utils/appMode' import type { ComfyApiWorkflow, ComfyWorkflowJSON diff --git a/browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts b/browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts new file mode 100644 index 00000000000..ed09ad8a624 --- /dev/null +++ b/browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts @@ -0,0 +1,243 @@ +import { expect } from '@playwright/test' +import type { Page } from '@playwright/test' + +import type { RemoteConfig } from '@/platform/remoteConfig/types' +import type { + Member, + WorkspaceWithRole +} from '@/platform/workspace/api/workspaceApi' + +import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' +import { mockSystemStats } from '@e2e/fixtures/data/systemStats' +import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper' + +/** + * The `?pricing=` deep link opens the pricing table on app load, gated to the + * original owner (canManageSubscriptionLifecycle). Drives a raw `page` so the + * cloud app boots against fully mocked endpoints, like the survey-gate spec. + */ +const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188' + +// CloudAuthHelper.mockAuth() signs in as this email; the original-owner gate +// matches it against the members self-row. +const SELF_EMAIL = 'e2e@test.comfy.org' + +function jsonRoute(body: unknown) { + return { + status: 200, + contentType: 'application/json', + body: JSON.stringify(body) + } +} + +async function mockCloudBoot(page: Page) { + // `/api/features` is the remote-config source; enable team workspaces so the + // unified pricing table (and the lifecycle gate) are live. + await page.route('**/api/features', (r) => + r.fulfill( + jsonRoute({ team_workspaces_enabled: true } satisfies RemoteConfig) + ) + ) + await page.route('**/api/system_stats', (r) => + r.fulfill(jsonRoute(mockSystemStats)) + ) + await page.route('**/api/users', (r) => + r.fulfill( + jsonRoute({ + storage: 'server', + migrated: true, + users: { 'test-user-e2e': 'E2E Test User' } + }) + ) + ) + await page.route('**/api/user', (r) => + r.fulfill(jsonRoute({ status: 'active' })) + ) + // Disable the experimental Asset API: with it on (cloud default) the + // unmocked asset endpoints 403 and workflow restore throws uncaught, + // aborting the GraphCanvas onMounted chain before the deep-link loader. + await page.route('**/api/settings', (r) => + r.fulfill(jsonRoute({ 'Comfy.Assets.UseAssetAPI': false })) + ) + await page.route('**/api/settings/**', (r) => r.fulfill(jsonRoute({}))) + await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([]))) + await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([]))) + await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({}))) + await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({}))) + // Queue/prompt status: a missing exec_info throws on boot and aborts the + // GraphCanvas onMounted chain before the deep-link loader runs. + await page.route('**/api/prompt', (r) => + r.fulfill(jsonRoute({ exec_info: { queue_remaining: 0 } })) + ) + await page.route('**/api/queue', (r) => + r.fulfill(jsonRoute({ queue_running: [], queue_pending: [] })) + ) + await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({}))) + await page.route('**/api/auth/session', (r) => + r.fulfill(jsonRoute({ token: 'mock-workspace-token' })) + ) + await page.route('**/releases**', (r) => r.fulfill(jsonRoute([]))) +} + +async function mockBilling(page: Page) { + // Minimal valid shapes so the billing facade resolves while the dialog mounts. + await page.route('**/api/billing/status', (r) => + r.fulfill( + jsonRoute({ + is_active: true, + has_funds: true, + subscription_status: 'active', + subscription_tier: 'pro', + subscription_duration: 'MONTHLY', + billing_status: 'paid' + }) + ) + ) + await page.route('**/api/billing/balance', (r) => + r.fulfill(jsonRoute({ amount_micros: 0, currency: 'usd' })) + ) + await page.route('**/api/billing/plans', (r) => + r.fulfill(jsonRoute({ plans: [] })) + ) + await page.route('**/customers/cloud-subscription-status', (r) => + r.fulfill(jsonRoute({ is_active: false })) + ) + await page.route('**/customers/balance', (r) => + r.fulfill(jsonRoute({ amount_micros: 0, currency: 'usd' })) + ) +} + +function workspace( + type: 'personal' | 'team', + role: 'owner' | 'member' +): WorkspaceWithRole { + return { + id: `ws-${type}`, + name: type === 'team' ? 'My Team' : 'Personal Workspace', + type, + role, + created_at: '2026-01-01T00:00:00Z', + joined_at: '2026-01-01T00:00:00Z' + } +} + +async function mockWorkspace( + page: Page, + ws: WorkspaceWithRole, + members: Member[] +) { + await page.route('**/api/workspaces', async (route) => { + if (route.request().method() !== 'GET') return route.fallback() + await route.fulfill(jsonRoute({ workspaces: [ws] })) + }) + await page.route('**/api/auth/token', (r) => + r.fulfill( + jsonRoute({ + token: 'mock-workspace-token', + expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + workspace: { id: ws.id, name: ws.name, type: ws.type }, + role: ws.role, + permissions: [] + }) + ) + ) + await page.route('**/api/workspace/members**', (r) => + r.fulfill( + jsonRoute({ + members, + pagination: { offset: 0, limit: 50, total: members.length } + }) + ) + ) +} + +async function bootCloud(page: Page) { + const auth = new CloudAuthHelper(page) + await auth.mockAuth() + // Pre-select the mock user to skip the user-select screen. + await page.addInitScript(() => { + localStorage.setItem('Comfy.userId', 'test-user-e2e') + }) +} + +const pricingHeading = (page: Page) => + page.getByRole('heading', { name: 'Choose a Plan' }) + +function member( + overrides: Partial & Pick +): Member { + return { + id: `user-${overrides.email}`, + name: overrides.email, + joined_at: '2026-01-01T00:00:00Z', + is_original_owner: false, + ...overrides + } +} + +test.describe('Pricing table deep link', { tag: '@cloud' }, () => { + test('opens the pricing table for a personal owner', async ({ page }) => { + test.setTimeout(60_000) + await mockCloudBoot(page) + await mockBilling(page) + await mockWorkspace(page, workspace('personal', 'owner'), []) + await bootCloud(page) + + await page.goto(`${APP_URL}/?pricing=1`) + + await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 }) + await expect(page).not.toHaveURL(/[?&]pricing=/) + }) + + test('opens on the Team tab for ?pricing=team', async ({ page }) => { + test.setTimeout(60_000) + await mockCloudBoot(page) + await mockBilling(page) + await mockWorkspace(page, workspace('personal', 'owner'), []) + await bootCloud(page) + + await page.goto(`${APP_URL}/?pricing=team`) + + await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 }) + await expect( + page.getByRole('button', { name: 'For Teams' }) + ).toHaveAttribute('aria-pressed', 'true') + }) + + test('opens for a team original owner', async ({ page }) => { + test.setTimeout(60_000) + await mockCloudBoot(page) + await mockBilling(page) + await mockWorkspace(page, workspace('team', 'owner'), [ + member({ email: SELF_EMAIL, role: 'owner', is_original_owner: true }) + ]) + await bootCloud(page) + + await page.goto(`${APP_URL}/?pricing=1`) + + await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 }) + }) + + test('is a silent no-op for a team member', async ({ page }) => { + test.setTimeout(60_000) + await mockCloudBoot(page) + await mockBilling(page) + await mockWorkspace(page, workspace('team', 'member'), [ + member({ + email: 'creator@test.comfy.org', + role: 'owner', + is_original_owner: true + }), + member({ email: SELF_EMAIL, role: 'member' }) + ]) + await bootCloud(page) + + await page.goto(`${APP_URL}/?pricing=1`) + + await page.waitForFunction(() => !!window.app?.extensionManager, null, { + timeout: 45_000 + }) + await expect(page).not.toHaveURL(/[?&]pricing=/) + await expect(pricingHeading(page)).toBeHidden() + }) +}) diff --git a/browser_tests/tests/maskEditor.spec.ts b/browser_tests/tests/maskEditor.spec.ts index 895bbb0ba0e..d6a084a6f97 100644 --- a/browser_tests/tests/maskEditor.spec.ts +++ b/browser_tests/tests/maskEditor.spec.ts @@ -32,6 +32,10 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => { await expect(dialog.getByText('Save')).toBeVisible() await expect(dialog.getByText('Cancel')).toBeVisible() + await dialog.getByTestId('pointer-zone').hover() + await dialog.getByText('Brush Settings').hover() + await expect(dialog.getByTestId('brush-cursor')).toHaveCSS('opacity', '0') + await comfyPage.expectScreenshot(dialog, 'mask-editor-dialog-open.png') } ) diff --git a/browser_tests/tests/maskEditor.spec.ts-snapshots/mask-editor-dialog-open-chromium-linux.png b/browser_tests/tests/maskEditor.spec.ts-snapshots/mask-editor-dialog-open-chromium-linux.png index a5d98d64e58..8bb40b6a724 100644 Binary files a/browser_tests/tests/maskEditor.spec.ts-snapshots/mask-editor-dialog-open-chromium-linux.png and b/browser_tests/tests/maskEditor.spec.ts-snapshots/mask-editor-dialog-open-chromium-linux.png differ diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue index ef90a492ccd..5bb516ae079 100644 --- a/src/components/graph/GraphCanvas.vue +++ b/src/components/graph/GraphCanvas.vue @@ -194,6 +194,7 @@ import { forEachNode } from '@/utils/graphTraversalUtil' import SelectionRectangle from './SelectionRectangle.vue' import { isCloud } from '@/platform/distribution/types' import { useFeatureFlags } from '@/composables/useFeatureFlags' +import { usePricingTableUrlLoader } from '@/platform/cloud/subscription/composables/usePricingTableUrlLoader' import { useCreateWorkspaceUrlLoader } from '@/platform/workspace/composables/useCreateWorkspaceUrlLoader' import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader' @@ -458,6 +459,7 @@ const { flags } = useFeatureFlags() // Set up URL loaders during setup phase so useRoute/useRouter work correctly const inviteUrlLoader = isCloud ? useInviteUrlLoader() : null const createWorkspaceUrlLoader = isCloud ? useCreateWorkspaceUrlLoader() : null +const pricingTableUrlLoader = isCloud ? usePricingTableUrlLoader() : null useCanvasDrop(canvasRef) useLitegraphSettings() useNodeBadge() @@ -583,6 +585,19 @@ onMounted(async () => { } } + // Open the pricing table from URL if present (e.g., ?pricing=1 / ?pricing=team). + // Not gated on the team-workspaces flag: it also drives personal/legacy users. + if (pricingTableUrlLoader) { + try { + await pricingTableUrlLoader.loadPricingTableFromUrl() + } catch (error) { + console.error( + '[GraphCanvas] Failed to load pricing table from URL:', + error + ) + } + } + // Initialize release store to fetch releases from comfy-api (fire-and-forget) const { useReleaseStore } = await import('@/platform/updates/common/releaseStore') diff --git a/src/platform/cloud/subscription/composables/usePricingTableUrlLoader.test.ts b/src/platform/cloud/subscription/composables/usePricingTableUrlLoader.test.ts new file mode 100644 index 00000000000..89f82df410c --- /dev/null +++ b/src/platform/cloud/subscription/composables/usePricingTableUrlLoader.test.ts @@ -0,0 +1,235 @@ +import { fromAny } from '@total-typescript/shoehorn' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { usePricingTableUrlLoader } from './usePricingTableUrlLoader' + +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 +})) +const mockRouterReplace = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)) + +vi.mock('vue-router', () => ({ + useRoute: () => ({ + query: mockRouteQuery.value + }), + useRouter: () => ({ + replace: mockRouterReplace + }) +})) + +const mockShowPricingTable = vi.hoisted(() => vi.fn()) + +vi.mock( + '@/platform/cloud/subscription/composables/useSubscriptionDialog', + () => ({ + useSubscriptionDialog: () => ({ + showPricingTable: mockShowPricingTable + }) + }) +) + +const mockPermissions = vi.hoisted(() => ({ + value: { canManageSubscriptionLifecycle: true } +})) + +vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({ + useWorkspaceUI: () => ({ permissions: mockPermissions }) +})) + +const mockFetchMembers = vi.hoisted(() => vi.fn().mockResolvedValue([])) + +vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({ + useTeamWorkspaceStore: () => ({ + fetchMembers: mockFetchMembers + }) +})) + +const mockTrackSubscription = vi.hoisted(() => vi.fn()) + +vi.mock('@/platform/telemetry', () => ({ + useTelemetry: () => ({ trackSubscription: mockTrackSubscription }) +})) + +describe('usePricingTableUrlLoader', () => { + beforeEach(() => { + vi.clearAllMocks() + mockRouteQuery.value = {} + mockPermissions.value = { canManageSubscriptionLifecycle: true } + // clearAllMocks resets calls, not implementations, so restore the default + // (a test overrides fetchMembers to flip the gate mid-await). + mockFetchMembers.mockResolvedValue([]) + preservedQueryMocks.mergePreservedQueryIntoQuery.mockReturnValue(null) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('does nothing when no pricing param present', async () => { + mockRouteQuery.value = {} + + const { loadPricingTableFromUrl } = usePricingTableUrlLoader() + await loadPricingTableFromUrl() + + expect(mockShowPricingTable).not.toHaveBeenCalled() + expect(mockRouterReplace).not.toHaveBeenCalled() + }) + + it('opens the pricing table for an original owner', async () => { + mockRouteQuery.value = { pricing: '1' } + + const { loadPricingTableFromUrl } = usePricingTableUrlLoader() + await loadPricingTableFromUrl() + + expect(mockShowPricingTable).toHaveBeenCalledWith({ + reason: 'deep_link', + planMode: undefined + }) + expect(mockTrackSubscription).toHaveBeenCalledWith('modal_opened', { + reason: 'deep_link' + }) + expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} }) + }) + + it('reads the gate only after members finish loading', async () => { + mockRouteQuery.value = { pricing: '1' } + // The original owner becomes known only once the members list resolves; + // proves the loader awaits fetchMembers before reading the gate. + mockPermissions.value = { canManageSubscriptionLifecycle: false } + mockFetchMembers.mockImplementation(async () => { + mockPermissions.value = { canManageSubscriptionLifecycle: true } + return [] + }) + + const { loadPricingTableFromUrl } = usePricingTableUrlLoader() + await loadPricingTableFromUrl() + + expect(mockShowPricingTable).toHaveBeenCalledOnce() + }) + + it('opens on the team tab for ?pricing=team', async () => { + mockRouteQuery.value = { pricing: 'team' } + + const { loadPricingTableFromUrl } = usePricingTableUrlLoader() + await loadPricingTableFromUrl() + + expect(mockShowPricingTable).toHaveBeenCalledWith({ + reason: 'deep_link', + planMode: 'team' + }) + }) + + it('opens on the personal tab for ?pricing=personal', async () => { + mockRouteQuery.value = { pricing: 'personal' } + + const { loadPricingTableFromUrl } = usePricingTableUrlLoader() + await loadPricingTableFromUrl() + + expect(mockShowPricingTable).toHaveBeenCalledWith({ + reason: 'deep_link', + planMode: 'personal' + }) + }) + + it('is a silent no-op for a member or promoted owner', async () => { + mockRouteQuery.value = { pricing: '1' } + mockPermissions.value = { canManageSubscriptionLifecycle: false } + + const { loadPricingTableFromUrl } = usePricingTableUrlLoader() + await loadPricingTableFromUrl() + + expect(mockShowPricingTable).not.toHaveBeenCalled() + expect(mockTrackSubscription).not.toHaveBeenCalled() + }) + + it('denies, strips, and clears together when the user is not eligible', async () => { + mockRouteQuery.value = { pricing: '1', other: 'param' } + mockPermissions.value = { canManageSubscriptionLifecycle: false } + + const { loadPricingTableFromUrl } = usePricingTableUrlLoader() + await loadPricingTableFromUrl() + + expect(mockShowPricingTable).not.toHaveBeenCalled() + expect(mockTrackSubscription).not.toHaveBeenCalled() + expect(mockRouterReplace).toHaveBeenCalledWith({ + query: { other: 'param' } + }) + expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith( + 'pricing' + ) + }) + + it('restores preserved query and opens the table', async () => { + mockRouteQuery.value = {} + preservedQueryMocks.mergePreservedQueryIntoQuery.mockReturnValue({ + pricing: '1' + }) + + const { loadPricingTableFromUrl } = usePricingTableUrlLoader() + await loadPricingTableFromUrl() + + expect(preservedQueryMocks.hydratePreservedQuery).toHaveBeenCalledWith( + 'pricing' + ) + expect(mockShowPricingTable).toHaveBeenCalledOnce() + }) + + it('ignores empty param', async () => { + mockRouteQuery.value = { pricing: '' } + + const { loadPricingTableFromUrl } = usePricingTableUrlLoader() + await loadPricingTableFromUrl() + + expect(mockShowPricingTable).not.toHaveBeenCalled() + expect(mockRouterReplace).not.toHaveBeenCalled() + }) + + it('ignores non-string param', async () => { + mockRouteQuery.value = { pricing: fromAny(['array']) } + + const { loadPricingTableFromUrl } = usePricingTableUrlLoader() + await loadPricingTableFromUrl() + + expect(mockShowPricingTable).not.toHaveBeenCalled() + }) + + it('opens the default tab for an unrecognized pricing value', async () => { + mockRouteQuery.value = { pricing: 'garbage' } + + const { loadPricingTableFromUrl } = usePricingTableUrlLoader() + await loadPricingTableFromUrl() + + expect(mockShowPricingTable).toHaveBeenCalledWith({ + reason: 'deep_link', + planMode: undefined + }) + }) + + it('strips and clears, then propagates a members-fetch failure', async () => { + mockRouteQuery.value = { pricing: '1' } + mockFetchMembers.mockRejectedValue(new Error('listMembers failed')) + + const { loadPricingTableFromUrl } = usePricingTableUrlLoader() + await expect(loadPricingTableFromUrl()).rejects.toThrow( + 'listMembers failed' + ) + + expect(mockShowPricingTable).not.toHaveBeenCalled() + expect(mockTrackSubscription).not.toHaveBeenCalled() + expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} }) + expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith( + 'pricing' + ) + }) +}) diff --git a/src/platform/cloud/subscription/composables/usePricingTableUrlLoader.ts b/src/platform/cloud/subscription/composables/usePricingTableUrlLoader.ts new file mode 100644 index 00000000000..02c21036228 --- /dev/null +++ b/src/platform/cloud/subscription/composables/usePricingTableUrlLoader.ts @@ -0,0 +1,67 @@ +import { useRoute, useRouter } from 'vue-router' + +import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog' +import { + clearPreservedQuery, + hydratePreservedQuery, + mergePreservedQueryIntoQuery +} from '@/platform/navigation/preservedQueryManager' +import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces' +import { useTelemetry } from '@/platform/telemetry' +import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI' +import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore' + +const NAMESPACE = PRESERVED_QUERY_NAMESPACES.PRICING + +/** + * Opens the pricing table from a `?pricing=` deep link, to send pilot users + * straight to subscribe. Values: `1` (default tab), `team`, `personal`. + * + * Gated to the original owner (`canManageSubscriptionLifecycle`); a member or + * promoted owner is a silent no-op with the param stripped. Survives the login + * redirect via the preserved-query system, like the invite URL loader. + */ +export function usePricingTableUrlLoader() { + const route = useRoute() + const router = useRouter() + const subscriptionDialog = useSubscriptionDialog() + const workspaceStore = useTeamWorkspaceStore() + const { permissions } = useWorkspaceUI() + + /** Reads `?pricing=`, strips it, and opens the table when the gate allows. */ + async function loadPricingTableFromUrl() { + hydratePreservedQuery(NAMESPACE) + const query = + mergePreservedQueryIntoQuery(NAMESPACE, route.query) ?? route.query + const param = query.pricing + if (!param || typeof param !== 'string') return + + // Strip the param (even for ineligible users) and write the clean URL in a + // single replace before any await, so a clean URL is guaranteed even if the + // replace rejects or the gate later denies the user. + const cleanQuery = { ...query } + delete cleanQuery.pricing + router.replace({ query: cleanQuery }).catch((error) => { + console.warn( + '[usePricingTableUrlLoader] Failed to clean URL params:', + error + ) + }) + clearPreservedQuery(NAMESPACE) + + // Fetch members (no-ops for personal) so the original-owner self-row loads + // before the gate; fetchMembers awaits, ensureMembersLoaded can return early. + await workspaceStore.fetchMembers() + if (!permissions.value.canManageSubscriptionLifecycle) return + + const planMode = + param === 'team' || param === 'personal' ? param : undefined + + useTelemetry()?.trackSubscription('modal_opened', { reason: 'deep_link' }) + subscriptionDialog.showPricingTable({ reason: 'deep_link', planMode }) + } + + return { + loadPricingTableFromUrl + } +} diff --git a/src/platform/cloud/subscription/composables/useSubscriptionDialog.ts b/src/platform/cloud/subscription/composables/useSubscriptionDialog.ts index d2cabee017a..bec1306da9f 100644 --- a/src/platform/cloud/subscription/composables/useSubscriptionDialog.ts +++ b/src/platform/cloud/subscription/composables/useSubscriptionDialog.ts @@ -15,6 +15,7 @@ export type SubscriptionDialogReason = | 'subscription_required' | 'out_of_credits' | 'top_up_blocked' + | 'deep_link' export interface SubscriptionDialogOptions { reason?: SubscriptionDialogReason diff --git a/src/platform/navigation/preservedQueryNamespaces.ts b/src/platform/navigation/preservedQueryNamespaces.ts index 13d1da4431e..65e2f1a13e8 100644 --- a/src/platform/navigation/preservedQueryNamespaces.ts +++ b/src/platform/navigation/preservedQueryNamespaces.ts @@ -4,5 +4,6 @@ export const PRESERVED_QUERY_NAMESPACES = { SHARE: 'share', SHARE_AUTH: 'share_auth', CREATE_WORKSPACE: 'create_workspace', - OAUTH: 'oauth' + OAUTH: 'oauth', + PRICING: 'pricing' } as const diff --git a/src/platform/workspace/api/workspaceApi.ts b/src/platform/workspace/api/workspaceApi.ts index 05a930e9b8b..3aa53aa571d 100644 --- a/src/platform/workspace/api/workspaceApi.ts +++ b/src/platform/workspace/api/workspaceApi.ts @@ -31,6 +31,11 @@ export interface Member { email: string joined_at: string role: WorkspaceRole + // True when this member is the workspace's original owner/creator + // (member.id == workspace.created_by_user_id). Gates the creator-only + // billing lifecycle actions (cancel / reactivate / downgrade). + // Optional: the cloud OpenAPI does not carry this field yet. + is_original_owner?: boolean } interface PaginationInfo { diff --git a/src/platform/workspace/components/CurrentUserPopoverWorkspace.vue b/src/platform/workspace/components/CurrentUserPopoverWorkspace.vue index e1de7e7514a..0c20c3df7e1 100644 --- a/src/platform/workspace/components/CurrentUserPopoverWorkspace.vue +++ b/src/platform/workspace/components/CurrentUserPopoverWorkspace.vue @@ -113,7 +113,11 @@ button-variant="gradient" />