Compare commits

..

1 Commits

Author SHA1 Message Date
huang47
ede5556644 fix(ci): strip public assets from e2e coverage 2026-06-24 18:01:45 -07:00
138 changed files with 2307 additions and 9946 deletions

View File

@@ -88,9 +88,9 @@ jobs:
- name: Strip non-source entries from coverage
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
# Drop served bundle scripts (localhost-8188/assets/*.js) that V8 records but have no source file on disk, which would abort genhtml.
lcov --remove coverage/playwright/coverage.lcov \
'*localhost-8188*' \
'assets/images/*' \
-o coverage/playwright/coverage.lcov \
--ignore-errors unused
wc -l coverage/playwright/coverage.lcov
@@ -121,7 +121,8 @@ jobs:
--title "ComfyUI E2E Coverage" \
--no-function-coverage \
--precision 1 \
--ignore-errors source,unmapped
--ignore-errors source,unmapped,range \
--synthesize-missing
- name: Upload HTML report artifact
if: steps.coverage-shards.outputs.has-coverage == 'true'

View File

@@ -83,16 +83,6 @@ const config: StorybookConfig = {
replacement:
process.cwd() + '/src/storybook/mocks/useBillingContext.ts'
},
{
find: '@/composables/useFeatureFlags',
replacement:
process.cwd() + '/src/storybook/mocks/useFeatureFlags.ts'
},
{
find: '@/platform/workspace/stores/teamWorkspaceStore',
replacement:
process.cwd() + '/src/storybook/mocks/teamWorkspaceStore.ts'
},
{
find: '@/utils/formatUtil',
replacement:

View File

@@ -1,96 +0,0 @@
import type {
BillingStatusResponse,
Member,
Plan,
WorkspaceWithRole
} from '@/platform/workspace/api/workspaceApi'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
// `/api/features` is the remote-config source: production builds resolve the
// workspaces flag from it (the `ff:` localStorage override is dev-only).
export const WORKSPACE_FEATURE_FLAG: RemoteConfig = {
team_workspaces_enabled: true
}
export const TEAM_WORKSPACE: WorkspaceWithRole = {
id: 'ws-team',
name: 'Team Comfy',
type: 'team',
created_at: '2025-01-01T00:00:00Z',
joined_at: '2025-01-02T00:00:00Z',
role: 'owner',
subscription_tier: 'PRO'
}
export const CREATOR: Member = {
id: 'u-liz',
name: 'Liz',
email: 'liz@test.comfy.org',
joined_at: '2025-01-01T00:00:00Z',
role: 'owner',
is_original_owner: true
}
// Identity must match the CloudAuthHelper mock user so this row counts as
// "(You)".
export const VIEWER: Member = {
id: 'u-me',
name: 'E2E Test User',
email: 'e2e@test.comfy.org',
joined_at: '2025-01-02T00:00:00Z',
role: 'owner',
is_original_owner: false
}
export const MEMBER_JANE: Member = {
id: 'u-jane',
name: 'Jane',
email: 'jane@test.comfy.org',
joined_at: '2025-01-03T00:00:00Z',
role: 'member',
is_original_owner: false
}
export const MEMBER_JOHN: Member = {
id: 'u-john',
name: 'John',
email: 'john@test.comfy.org',
joined_at: '2025-01-04T00:00:00Z',
role: 'member',
is_original_owner: false
}
export const DEFAULT_TEAM_MEMBERS: Member[] = [
CREATOR,
VIEWER,
MEMBER_JANE,
MEMBER_JOHN
]
export const TEAM_BILLING_STATUS: BillingStatusResponse = {
is_active: true,
subscription_status: 'active',
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY',
plan_slug: 'pro-monthly',
billing_status: 'paid',
has_funds: true,
renewal_date: '2099-02-20T00:00:00Z'
}
// `max_seats > 1` on the current plan is what flips `isOnTeamPlan`, which gates
// the whole role-management UI.
export const TEAM_PRO_PLAN: Plan = {
slug: 'pro-monthly',
tier: 'PRO',
duration: 'MONTHLY',
price_cents: 10000,
credits_cents: 21100,
max_seats: 30,
availability: { available: true },
seat_summary: {
seat_count: 4,
total_cost_cents: 40000,
total_credits_cents: 0
}
}

View File

@@ -1,150 +0,0 @@
import type { Page, Route } from '@playwright/test'
import type { Member } from '@/platform/workspace/api/workspaceApi'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import {
DEFAULT_TEAM_MEMBERS,
TEAM_BILLING_STATUS,
TEAM_PRO_PLAN,
TEAM_WORKSPACE,
WORKSPACE_FEATURE_FLAG
} from '@e2e/fixtures/data/cloudWorkspace'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
interface RoleChangeRequest {
url: string
role: string
}
interface MemberMockState {
members: Member[]
patches: RoleChangeRequest[]
}
const jsonRoute = (body: unknown) => ({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
})
/**
* Boots the cloud app against fully mocked workspace + billing endpoints so
* member/role specs can drive a raw `page` (the `comfyPage` fixture would try
* to reach the OSS devtools backend during setup).
*
* Returns the mutable mock state: `members` reflects PATCH-applied roles and
* `patches` records every role-change request for assertion.
*/
export class CloudWorkspaceMockHelper {
constructor(private readonly page: Page) {}
async setup(
members: Member[] = DEFAULT_TEAM_MEMBERS
): Promise<MemberMockState> {
const state = await this.mockBoot(members)
await new CloudAuthHelper(this.page).mockAuth()
await this.page.addInitScript(() => {
localStorage.setItem('Comfy.userId', 'test-user-e2e')
localStorage.setItem('Comfy.Workspace.LastWorkspaceId', 'ws-team')
})
return state
}
private async mockBoot(members: Member[]): Promise<MemberMockState> {
const state: MemberMockState = {
members: members.map((m) => ({ ...m })),
patches: []
}
const { page } = this
await page.route('**/api/features', (r) =>
r.fulfill(jsonRoute(WORKSPACE_FEATURE_FLAG))
)
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' }
})
)
)
// A non-empty settings payload with TutorialCompleted marks the user as
// returning, so the new-user Templates dialog never auto-opens to block the
// Settings button. Errors tab off suppresses the model-folder 401 toast.
await page.route('**/api/settings', (r) =>
r.fulfill(
jsonRoute({
'Comfy.TutorialCompleted': true,
'Comfy.RightSidePanel.ShowErrorsTab': false
})
)
)
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({})))
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('**/api/auth/token', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/workspaces', (r) =>
r.fulfill(jsonRoute({ workspaces: [TEAM_WORKSPACE] }))
)
await page.route('**/api/workspace/members**', (route: Route) => {
const request = route.request()
if (request.method() === 'PATCH') {
const url = request.url()
const id = url.match(/\/api\/workspace\/members\/([^/?]+)/)?.[1]
const { role } = request.postDataJSON() as { role: Member['role'] }
state.patches.push({ url, role })
const member = state.members.find((m) => m.id === id)
if (member) member.role = role
// Echo the updated row like the real BE; the store merges only the role
// locally, so the response body shape is not load-bearing.
return route.fulfill(jsonRoute(member))
}
return route.fulfill(
jsonRoute({
members: state.members,
pagination: { offset: 0, limit: 50, total: state.members.length }
})
)
})
await page.route('**/api/workspace/invites', (r) =>
r.fulfill(jsonRoute({ invites: [] }))
)
await page.route('**/api/billing/status', (r) =>
r.fulfill(jsonRoute(TEAM_BILLING_STATUS))
)
await page.route('**/api/billing/balance', (r) =>
r.fulfill(
jsonRoute({
amount_micros: 6000,
currency: 'usd',
effective_balance_micros: 6000,
cloud_credit_balance_micros: 5000,
prepaid_balance_micros: 1000
})
)
)
await page.route('**/api/billing/plans', (r) =>
r.fulfill(
jsonRoute({ current_plan_slug: 'pro-monthly', plans: [TEAM_PRO_PLAN] })
)
)
return state
}
}

View File

@@ -1,34 +0,0 @@
import type { Page } from '@playwright/test'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
/**
* Minimal valid billing shapes so the billing facade resolves while a
* subscription dialog mounts. Active personal sub with zero balance.
*/
export async function mockBilling(page: Page) {
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' }))
)
}

View File

@@ -1,64 +0,0 @@
import type { Page } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
interface CloudBootOptions {
/** Remote-config payload for `/api/features` (enables the flags under test). */
features: RemoteConfig
/** Body for `/api/settings` (defaults to `{}`). */
settings?: unknown
}
/**
* Stub the core endpoints the cloud app hits on boot so a raw `page` reaches the
* working app without falling through to the OSS devtools backend. Specs layer
* their own feature- or flow-specific routes on top.
*/
export async function mockCloudBoot(
page: Page,
{ features, settings = {} }: CloudBootOptions
) {
await page.route('**/api/features', (r) => r.fulfill(jsonRoute(features)))
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' }))
)
await page.route('**/api/settings', (r) => r.fulfill(jsonRoute(settings)))
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({})))
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([])))
}
/**
* Mock Firebase auth and pre-select the e2e user so the cloud app boots
* signed-in. The signed-in email (`e2e@test.comfy.org`) is what the
* original-owner gate matches against the members self-row.
*/
export async function bootCloud(page: Page) {
const auth = new CloudAuthHelper(page)
await auth.mockAuth()
await page.addInitScript(() => {
localStorage.setItem('Comfy.userId', 'test-user-e2e')
})
}

View File

@@ -1,12 +0,0 @@
/**
* Build a 200 JSON body for `route.fulfill()`. Generic so callers can type the
* payload (e.g. `jsonRoute({ ... } satisfies RemoteConfig)`) and catch contract
* drift against the real API shape.
*/
export function jsonRoute<T>(body: T) {
return {
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
}
}

View File

@@ -1,68 +0,0 @@
import type { Page } from '@playwright/test'
import type {
Member,
WorkspaceWithRole
} from '@/platform/workspace/api/workspaceApi'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
export 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'
}
}
export function member(
overrides: Partial<Member> & Pick<Member, 'email' | 'role'>
): Member {
return {
id: `user-${overrides.email}`,
name: overrides.email,
joined_at: '2026-01-01T00:00:00Z',
is_original_owner: false,
...overrides
}
}
/**
* Stub the workspace resolution + members list so the cloud app boots into the
* given workspace with the given roster (drives the original-owner gate).
*/
export 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 }
})
)
)
}

View File

@@ -3,10 +3,6 @@ import type { Page } from '@playwright/test'
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type {
BillingBalanceResponse,
BillingStatusResponse
} from '@/platform/workspace/api/workspaceApi'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
@@ -16,12 +12,12 @@ import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
* Billing facade consumers — FE-933 (B3) regression.
*
* The repointed surfaces (avatar popover tier badge / balance, free-tier
* dialog renewal date) must keep rendering from `useBillingContext`. The facade
* selects its backend by flag: `team_workspaces_enabled: false` routes through
* the legacy `/customers/*` endpoints, while `true` routes a personal workspace
* through the workspace `/api/billing/*` endpoints. Both shapes are mocked here.
* Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
* against fully mocked endpoints — same pattern as creditsTile.spec.ts.
* dialog renewal date) must keep rendering from `useBillingContext`, which in
* a personal workspace routes through the legacy `/customers/*` endpoints
* (mocked here). Drives a raw `page` (not the `comfyPage` fixture) so the
* cloud app boots against fully mocked endpoints — same pattern as
* creditsTile.spec.ts. `team_workspaces_enabled: false` keeps the topbar on
* the legacy popover variant that FE-933 repointed.
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
@@ -31,25 +27,6 @@ const jsonRoute = (body: unknown) => ({
body: JSON.stringify(body)
})
// The workspace `/api/billing/status` shape mirrors the legacy subscription
// status; map the fields so a single test fixture drives both backends.
const toWorkspaceStatus = (
s: CloudSubscriptionStatusResponse
): BillingStatusResponse => ({
is_active: s.is_active ?? false,
subscription_tier: s.subscription_tier ?? undefined,
subscription_duration: s.subscription_duration ?? undefined,
renewal_date: s.renewal_date ?? undefined,
cancel_at: s.end_date ?? undefined,
has_funds: s.has_fund ?? true
})
const mockBalance: BillingBalanceResponse = {
amount_micros: 6000, // -> 12,660 credits
currency: 'usd',
effective_balance_micros: 6000
}
async function mockCloudBoot(
page: Page,
subscriptionStatus: CloudSubscriptionStatusResponse,
@@ -83,7 +60,8 @@ async function mockCloudBoot(
)
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
// Single personal workspace.
// Single personal workspace: keeps the billing facade on the legacy
// `/customers/*` path when team workspaces are enabled.
await page.route('**/api/workspaces', (r) =>
r.fulfill(
jsonRoute({
@@ -99,24 +77,17 @@ async function mockCloudBoot(
)
)
// Legacy backend (team_workspaces_enabled: false).
await page.route('**/customers/cloud-subscription-status', (r) =>
r.fulfill(jsonRoute(subscriptionStatus))
)
await page.route('**/customers/balance', (r) =>
r.fulfill(jsonRoute(mockBalance))
)
// Workspace backend (team_workspaces_enabled: true) — a personal workspace
// now routes through `/api/billing/*`.
await page.route('**/api/billing/status', (r) =>
r.fulfill(jsonRoute(toWorkspaceStatus(subscriptionStatus)))
)
await page.route('**/api/billing/balance', (r) =>
r.fulfill(jsonRoute(mockBalance))
)
await page.route('**/api/billing/plans', (r) =>
r.fulfill(jsonRoute({ plans: [] }))
r.fulfill(
jsonRoute({
amount_micros: 6000, // -> 12,660 credits
currency: 'usd',
effective_balance_micros: 6000
})
)
)
}
@@ -163,10 +134,10 @@ test.describe('Billing facade consumers (FE-933)', { tag: '@cloud' }, () => {
}) => {
test.setTimeout(60_000)
// Boots with team workspaces enabled (production shape); the facade routes a
// personal workspace through the workspace `/api/billing/*` endpoints. With
// subscription gating on, an inactive FREE user gets the "Subscribe to run"
// button, which opens the free-tier dialog on click. (refreshRemoteConfig
// Boots with team workspaces enabled (production shape); the facade still
// routes a personal workspace through `/customers/*`. With subscription
// gating on, an inactive FREE user gets the "Subscribe to run" button,
// which opens the free-tier dialog on click. (refreshRemoteConfig
// overwrites window.__CONFIG__ from /api/features, so the flags must come
// from the features mock, not an init script.)
await mockCloudBoot(

View File

@@ -4,7 +4,8 @@ import type { Page } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
/**
* getSurveyCompletedStatus fails safe: a transient 401 on `/` must not bounce a
@@ -15,12 +16,51 @@ import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
// `/api/features` is the remote-config source: production builds resolve
// `onboardingSurveyEnabled` from it (the `ff:` localStorage override is
// dev-only). Enable the survey so the gate is actually live.
const BOOT_FEATURES = {
onboarding_survey_enabled: true
} satisfies RemoteConfig
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: production builds resolve
// `onboardingSurveyEnabled` from it (the `ff:` localStorage override is
// dev-only). Enable the survey so the gate is actually live.
await page.route('**/api/features', (r) =>
r.fulfill(
jsonRoute({ onboarding_survey_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' }
})
)
)
// Cloud user status (getUserCloudStatus) — an active account so the gate
// proceeds to the survey check instead of bouncing back to login.
await page.route('**/api/user', (r) =>
r.fulfill(jsonRoute({ status: 'active' }))
)
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({})))
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([])))
}
// Genuine "not completed": the cloud backend returns 404 for a survey key that
// was never stored. This is the response that must still route to the survey.
@@ -49,13 +89,22 @@ async function mockSurveyTransient401(page: Page) {
)
}
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')
})
}
test.describe('Cloud onboarding survey gate', { tag: '@cloud' }, () => {
test('a transient 401 on the survey check does not bounce a working user to the survey', async ({
page
}) => {
test.slow()
test.setTimeout(60_000)
await mockCloudBoot(page, { features: BOOT_FEATURES })
await mockCloudBoot(page)
await mockSurveyTransient401(page)
await bootCloud(page)
@@ -73,9 +122,9 @@ test.describe('Cloud onboarding survey gate', { tag: '@cloud' }, () => {
test('a not-completed (404) user landing on / is routed to the survey', async ({
page
}) => {
test.slow()
test.setTimeout(60_000)
await mockCloudBoot(page, { features: BOOT_FEATURES })
await mockCloudBoot(page)
await mockSurveyNotCompleted(page)
await bootCloud(page)

View File

@@ -2,10 +2,7 @@ import { expect } from '@playwright/test'
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type {
BillingStatusResponse,
WorkspaceWithRole
} from '@/platform/workspace/api/workspaceApi'
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
import type { WorkspaceTokenResponse } from '@/platform/workspace/stores/workspaceAuthStore'
import type { operations } from '@/types/comfyRegistryTypes'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
@@ -54,20 +51,6 @@ const mockSubscriptionStatus: CloudSubscriptionStatusResponse = {
end_date: FUTURE_DATE
}
// With team workspaces enabled, the facade routes a personal workspace through
// `/api/billing/*`. The cancelled-but-active state maps to `is_active: true`
// with `subscription_status: 'canceled'`; a paid tier keeps "Add credits"
// visible (free tier would swap it for "Upgrade to add credits").
const mockBillingStatus: BillingStatusResponse = {
is_active: true,
subscription_status: 'canceled',
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY',
has_funds: true,
cancel_at: FUTURE_DATE,
renewal_date: FUTURE_DATE
}
// ~6.3M credits — a 7-digit balance is what pushes the second action button out
// of the popover before the fix.
const mockBalance: CustomerBalanceResponse = {
@@ -122,32 +105,6 @@ const test = comfyPageFixture.extend({
})
)
// Flag-on (team workspaces enabled) routes a personal workspace through the
// workspace billing endpoints, so the popover sources its data from here.
await page.route('**/api/billing/status', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockBillingStatus)
})
)
await page.route('**/api/billing/balance', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockBalance)
})
)
await page.route('**/api/billing/plans', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ plans: [] })
})
)
await use(page)
}
})

View File

@@ -1,264 +0,0 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type { BillingStatusResponse } 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'
// Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
// against fully mocked endpoints; `comfyPage` would try to reach the OSS
// devtools backend during setup.
/**
* Credits tile (Settings ▸ Workspace ▸ Plan & Credits) — DES-247 / FE-964.
*
* The credits tile only lives inside the authenticated cloud app, which the
* shared `comfyPage` fixture can't boot (it expects the OSS devtools backend).
* Instead this drives a raw page: mock Firebase auth + every boot endpoint so
* the cloud app initializes against fully stubbed data. With team workspaces
* enabled the facade routes a personal workspace through the workspace
* `/api/billing/*` endpoints (mocked with an active Pro subscription); the
* legacy `/customers/*` shapes are mocked too for the flag-off path. The tile
* should then render its total / progress bar / monthly+additional breakdown /
* add-credits.
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
const jsonRoute = (body: unknown) => ({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
})
// Legacy `/customers/balance` and workspace `/api/billing/balance` share the
// same response shape, so one body fulfills both endpoints.
const balanceRoute = (balance: {
amount: number
monthly: number
prepaid: number
}) =>
jsonRoute({
amount_micros: balance.amount,
currency: 'usd',
effective_balance_micros: balance.amount,
cloud_credit_balance_micros: balance.monthly,
prepaid_balance_micros: balance.prepaid
})
// 6000 -> 12,660 total; 5000 -> 10,550 monthly remaining; 1000 -> 2,110 extra.
const DEFAULT_BALANCE = { amount: 6000, monthly: 5000, prepaid: 1000 }
const mockBillingStatus: BillingStatusResponse = {
is_active: true,
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY',
renewal_date: '2099-02-20T12:00:00Z',
has_funds: true
}
async function mockCloudBoot(page: Page) {
// Frontend-origin boot endpoints (proxied to the backend in production).
// `/api/features` is the remote-config source: production builds resolve
// `teamWorkspacesEnabled` from it (the `ff:` localStorage override is
// dev-only), and the flag gates the Workspace settings panel.
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))
)
// Include the mock user so the multi-user select screen auto-selects it
// (paired with the `Comfy.userId` localStorage seed below).
await page.route('**/api/users', (r) =>
r.fulfill(
jsonRoute({
storage: 'server',
migrated: true,
users: { 'test-user-e2e': 'E2E Test User' }
})
)
)
// Non-empty settings with a completed tutorial keep the cloud app from
// booting as a new user, whose Workflow Templates dialog would otherwise
// auto-open and intercept the Settings click behind its modal backdrop.
await page.route('**/api/settings', (r) =>
r.fulfill(jsonRoute({ 'Comfy.TutorialCompleted': true }))
)
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({})))
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([])))
// Single personal workspace.
await page.route('**/api/workspaces', (r) =>
r.fulfill(
jsonRoute({
workspaces: [
{
id: 'ws-personal',
name: 'Personal Workspace',
type: 'personal',
role: 'owner'
}
]
})
)
)
// Legacy billing (flag-off path, api.comfy.org/customers/*).
await page.route('**/customers/cloud-subscription-status', (r) =>
r.fulfill(
jsonRoute({
is_active: true,
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY',
renewal_date: '2099-02-20T12:00:00Z',
end_date: null
})
)
)
await page.route('**/customers/balance', (r) =>
r.fulfill(balanceRoute(DEFAULT_BALANCE))
)
// Workspace billing (flag-on path) — a personal workspace now routes through
// `/api/billing/*`.
await page.route('**/api/billing/status', (r) =>
r.fulfill(jsonRoute(mockBillingStatus))
)
await page.route('**/api/billing/balance', (r) =>
r.fulfill(balanceRoute(DEFAULT_BALANCE))
)
await page.route('**/api/billing/plans', (r) =>
r.fulfill(jsonRoute({ plans: [] }))
)
}
async function mockBalance(
page: Page,
balance: { amount: number; monthly: number; prepaid: number }
) {
await page.unroute('**/customers/balance')
await page.unroute('**/api/billing/balance')
await page.route('**/customers/balance', (r) =>
r.fulfill(balanceRoute(balance))
)
await page.route('**/api/billing/balance', (r) =>
r.fulfill(balanceRoute(balance))
)
}
/** Boots the mocked cloud app and opens Settings ▸ Workspace ▸ Plan & Credits. */
async function openPlanAndCredits(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')
})
await page.goto(APP_URL)
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
// Open Settings ▸ Workspace.
await page
.getByRole('button', { name: /^Settings/ })
.first()
.click()
const dialog = page.getByTestId('settings-dialog')
await expect(dialog).toBeVisible()
await dialog.locator('nav').getByRole('button', { name: 'Workspace' }).click()
return dialog.getByRole('main')
}
test.describe('Credits tile (Plan & Credits)', { tag: '@cloud' }, () => {
test('renders the unified tile with breakdown and add-credits', async ({
page
}) => {
test.setTimeout(60_000)
await mockCloudBoot(page)
const content = await openPlanAndCredits(page)
// Total + remaining suffix (Pro monthly allowance = 21,100; remaining
// 10,550 -> used 10,550).
await expect(content.getByText('Total credits')).toBeVisible()
await expect(content.getByText('12,660')).toBeVisible()
// Monthly usage bar header + used / left-of-total labels.
await expect(content.getByText('Monthly', { exact: true })).toBeVisible()
await expect(content.getByText(/Refills Feb/)).toBeVisible()
await expect(content.getByText('10,550 used')).toBeVisible()
await expect(content.getByText('10,550 left of 21,100')).toBeVisible()
// Additional credits row + subtitle.
await expect(content.getByText('Additional credits')).toBeVisible()
await expect(content.getByText('2,110')).toBeVisible()
await expect(content.getByText('Used after monthly runs out')).toBeVisible()
// Permission-gated add-credits action (personal owner can top up).
await expect(
content.getByRole('button', { name: 'Add credits' })
).toBeVisible()
// Narrow container (DES-247 responsive variants): drop the used/remaining
// labels and the breakdown subtitle, compact the monthly summary numbers.
await page.setViewportSize({ width: 360, height: 800 })
await expect(content.getByText('10,550 used')).toBeHidden()
await expect(content.getByText('remaining', { exact: true })).toBeHidden()
await expect(content.getByText('Used after monthly runs out')).toBeHidden()
await expect(content.getByText('10,550 left of 21,100')).toBeHidden()
await expect(content.getByText('11K left of 21K')).toBeVisible()
})
test('renders the depleted-credit empty states', async ({ page }) => {
test.setTimeout(60_000)
await mockCloudBoot(page)
// Monthly allowance fully spent; additional credits keep generation going.
await mockBalance(page, { amount: 1000, monthly: 0, prepaid: 1000 })
const content = await openPlanAndCredits(page)
// 0-monthly state: depletion notice + IN USE badge on additional credits.
await expect(
content.getByText('Monthly credits are used up. Refills Feb 20')
).toBeVisible()
await expect(
content.getByText("You're now spending additional credits.")
).toBeVisible()
await expect(content.getByText('In use')).toBeVisible()
await expect(content.getByText('0 left of 21,100')).toBeVisible()
// Drain the remaining additional credits and refresh the tile: the
// out-of-credits notice takes over and the badge drops.
await mockBalance(page, { amount: 0, monthly: 0, prepaid: 0 })
await content.getByRole('button', { name: 'Refresh credits' }).click()
await expect(
content.getByText("You're out of credits. Credits refill Feb 20")
).toBeVisible()
await expect(
content.getByText('Add more credits to continue generating.')
).toBeVisible()
await expect(content.getByText('In use')).toBeHidden()
await expect(
content.getByRole('button', { name: 'Add credits' })
).toBeVisible()
})
})

View File

@@ -1,264 +0,0 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import type { Member } from '@/platform/workspace/api/workspaceApi'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import {
CREATOR,
MEMBER_JANE,
MEMBER_JOHN,
VIEWER
} from '@e2e/fixtures/data/cloudWorkspace'
import { CloudWorkspaceMockHelper } from '@e2e/fixtures/helpers/CloudWorkspaceMockHelper'
// Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
// against fully mocked endpoints; `comfyPage` would try to reach the OSS
// devtools backend during setup.
/**
* Member role change (Settings ▸ Workspace ▸ Members) — Figma 2993-15512.
*
* The viewer is a promoted owner (not the workspace creator), so the spec can
* distinguish the creator guard from the self guard: the creator row and the
* viewer's own row hide the row menu, every other row exposes
* "Change role " (Owner / Member) plus "Remove member". Promoting a member
* sends PATCH /api/workspace/members/:id {role}, flips the Role column,
* re-sorts the row under the creator, and the promoted owner stays demotable.
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
async function openMembersTab(page: Page): Promise<Locator> {
await page.goto(APP_URL)
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
await page
.getByRole('button', { name: /^Settings/ })
.first()
.click()
const dialog = page.getByTestId('settings-dialog')
await expect(dialog).toBeVisible()
await dialog.locator('nav').getByRole('button', { name: 'Workspace' }).click()
const content = dialog.getByRole('main')
await content.getByRole('tab', { name: /Members/ }).click()
await expect(content.getByText('4 of 30 members')).toBeVisible()
return content
}
function memberRow(content: Locator, email: string): Locator {
return content
.locator('div.grid')
.filter({ has: content.page().getByText(email, { exact: true }) })
}
function menuButton(row: Locator): Locator {
return row.getByRole('button', { name: 'More Options' })
}
// Reka submenus open on real pointer travel or keyboard; Playwright's
// synthetic hover doesn't trigger the pointermove handler, so drive the
// subtrigger with ArrowRight instead.
async function openChangeRoleSubmenu(page: Page) {
const trigger = page.getByRole('menuitem', { name: 'Change role' })
await expect(trigger).toBeVisible()
await trigger.press('ArrowRight')
await expect(
page.getByRole('menuitemradio', { name: 'Owner', exact: true })
).toBeVisible()
}
test.describe('Member role change (Members tab)', { tag: '@cloud' }, () => {
test.describe.configure({ timeout: 60_000 })
test('row menus respect creator and self guards', async ({ page }) => {
await new CloudWorkspaceMockHelper(page).setup()
const content = await openMembersTab(page)
// US8/US9 — no row actions on the creator row (Liz) nor on the viewer's
// own row; the two plain members each expose a menu.
await expect(
menuButton(memberRow(content, MEMBER_JOHN.email))
).toBeVisible()
await expect(
menuButton(memberRow(content, MEMBER_JANE.email))
).toBeVisible()
await expect(menuButton(memberRow(content, CREATOR.email))).toHaveCount(0)
await expect(menuButton(memberRow(content, VIEWER.email))).toHaveCount(0)
// US1/US12 — the row menu exposes Change role and the FE-768 remove flow.
await menuButton(memberRow(content, MEMBER_JANE.email)).click()
await expect(
page.getByRole('menuitem', { name: 'Change role' })
).toBeVisible()
await page.getByRole('menuitem', { name: 'Remove member' }).click()
await expect(page.getByText('Remove this member?')).toBeVisible()
})
test('selecting the current role is a no-op', async ({ page }) => {
const state = await new CloudWorkspaceMockHelper(page).setup()
const content = await openMembersTab(page)
const janeRow = memberRow(content, MEMBER_JANE.email)
await menuButton(janeRow).click()
await openChangeRoleSubmenu(page)
// The current role is a checked radio item so assistive tech can announce
// which role is active.
await expect(
page.getByRole('menuitemradio', { name: 'Member', exact: true })
).toHaveAttribute('aria-checked', 'true')
await expect(
page.getByRole('menuitemradio', { name: 'Owner', exact: true })
).toHaveAttribute('aria-checked', 'false')
await page
.getByRole('menuitemradio', { name: 'Member', exact: true })
.click()
await expect(page.getByRole('heading', { name: /an owner\?/ })).toHaveCount(
0
)
expect(state.patches).toHaveLength(0)
})
test('promote dialog shows the Figma copy and cancelling keeps the role', async ({
page
}) => {
const state = await new CloudWorkspaceMockHelper(page).setup()
const content = await openMembersTab(page)
const janeRow = memberRow(content, MEMBER_JANE.email)
await menuButton(janeRow).click()
await openChangeRoleSubmenu(page)
await page
.getByRole('menuitemradio', { name: 'Owner', exact: true })
.click()
await expect(
page.getByRole('heading', { name: 'Make Jane an owner?' })
).toBeVisible()
await expect(page.getByText("They'll be able to:")).toBeVisible()
await expect(page.getByText('Add additional credits')).toBeVisible()
await expect(
page.getByText('Manage members, payment methods, and workspace settings')
).toBeVisible()
await expect(
page.getByText(
'Promote and demote other owners (except the workspace creator).'
)
).toBeVisible()
await page.getByRole('button', { name: 'Cancel', exact: true }).click()
await expect(
page.getByRole('heading', { name: 'Make Jane an owner?' })
).toHaveCount(0)
await expect(janeRow.getByText('Member', { exact: true })).toBeVisible()
expect(state.patches).toHaveLength(0)
})
test('promoting a member re-sorts the row under the creator and stays demotable', async ({
page
}) => {
const state = await new CloudWorkspaceMockHelper(page).setup()
const content = await openMembersTab(page)
const emails = content.getByText(/@test\.comfy\.org/)
await expect(emails).toHaveText([
CREATOR.email,
VIEWER.email,
MEMBER_JOHN.email,
MEMBER_JANE.email
])
const janeRow = memberRow(content, MEMBER_JANE.email)
await menuButton(janeRow).click()
await openChangeRoleSubmenu(page)
await page
.getByRole('menuitemradio', { name: 'Owner', exact: true })
.click()
await page.getByRole('button', { name: 'Make owner' }).click()
await expect(page.getByText('Role updated')).toBeVisible()
await expect(janeRow.getByText('Owner', { exact: true })).toBeVisible()
await expect(emails).toHaveText([
CREATOR.email,
VIEWER.email,
MEMBER_JANE.email,
MEMBER_JOHN.email
])
expect(state.patches).toEqual([
{
url: expect.stringContaining('/api/workspace/members/u-jane'),
role: 'owner'
}
])
// The promoted owner keeps its row menu (still demotable).
await expect(menuButton(janeRow)).toBeVisible()
})
test('demoting an owner returns them to member', async ({ page }) => {
const ownerJane: Member = { ...MEMBER_JANE, role: 'owner' }
const state = await new CloudWorkspaceMockHelper(page).setup([
CREATOR,
VIEWER,
ownerJane,
MEMBER_JOHN
])
const content = await openMembersTab(page)
const janeRow = memberRow(content, MEMBER_JANE.email)
await expect(janeRow.getByText('Owner', { exact: true })).toBeVisible()
await menuButton(janeRow).click()
await openChangeRoleSubmenu(page)
await page
.getByRole('menuitemradio', { name: 'Member', exact: true })
.click()
await expect(
page.getByRole('heading', { name: 'Demote Jane to member?' })
).toBeVisible()
await expect(page.getByText("They'll lose admin access.")).toBeVisible()
await page.getByRole('button', { name: 'Demote to member' }).click()
await expect(janeRow.getByText('Member', { exact: true })).toBeVisible()
expect(state.patches).toEqual([
{
url: expect.stringContaining('/api/workspace/members/u-jane'),
role: 'member'
}
])
})
test('failed role change keeps the dialog open with an error toast', async ({
page
}) => {
await new CloudWorkspaceMockHelper(page).setup()
// Override the member route so PATCH fails after boot succeeds.
await page.route('**/api/workspace/members/**', (route) =>
route.request().method() === 'PATCH'
? route.fulfill({ status: 500, body: '{}' })
: route.fallback()
)
const content = await openMembersTab(page)
const janeRow = memberRow(content, MEMBER_JANE.email)
await menuButton(janeRow).click()
await openChangeRoleSubmenu(page)
await page
.getByRole('menuitemradio', { name: 'Owner', exact: true })
.click()
await page.getByRole('button', { name: 'Make owner' }).click()
// US10 — error toast, dialog stays open, role unchanged.
await expect(page.getByText('Failed to update role')).toBeVisible()
await expect(
page.getByRole('heading', { name: 'Make Jane an owner?' })
).toBeVisible()
await page.getByRole('button', { name: 'Cancel', exact: true }).click()
await expect(janeRow.getByText('Member', { exact: true })).toBeVisible()
})
})

View File

@@ -1,128 +0,0 @@
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 { mockBilling } from '@e2e/fixtures/utils/cloudBillingMocks'
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
import {
member,
mockWorkspace,
workspace
} from '@e2e/fixtures/utils/workspaceMocks'
/**
* 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'
const BOOT_FEATURES = { team_workspaces_enabled: true } satisfies RemoteConfig
// 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.
const BOOT_SETTINGS = { 'Comfy.Assets.UseAssetAPI': false }
// The deep-link loader runs at the tail of GraphCanvas onMounted, so the boot
// chain must not throw before it: a missing settings subpath, prompt exec_info,
// or queue status each abort that chain.
async function mockGraphBootExtras(page: Page) {
// Boot only reads these; fall back on any write so an unexpected POST/PUT
// surfaces instead of being masked by a blanket 200.
await page.route('**/api/settings/**', (route) => {
if (route.request().method() !== 'GET') return route.fallback()
return route.fulfill(jsonRoute({}))
})
await page.route('**/api/prompt', (route) => {
if (route.request().method() !== 'GET') return route.fallback()
return route.fulfill(jsonRoute({ exec_info: { queue_remaining: 0 } }))
})
await page.route('**/api/queue', (route) => {
if (route.request().method() !== 'GET') return route.fallback()
return route.fulfill(jsonRoute({ queue_running: [], queue_pending: [] }))
})
}
async function setupCloudApp(
page: Page,
ws: WorkspaceWithRole,
members: Member[]
) {
await mockCloudBoot(page, {
features: BOOT_FEATURES,
settings: BOOT_SETTINGS
})
await mockGraphBootExtras(page)
await mockBilling(page)
await mockWorkspace(page, ws, members)
await bootCloud(page)
}
const pricingHeading = (page: Page) =>
page.getByRole('heading', { name: 'Choose a Plan' })
test.describe('Pricing table deep link', { tag: '@cloud' }, () => {
test('opens the pricing table for a personal owner', async ({ page }) => {
test.slow()
await setupCloudApp(page, workspace('personal', 'owner'), [])
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.slow()
await setupCloudApp(page, workspace('personal', 'owner'), [])
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.slow()
await setupCloudApp(page, workspace('team', 'owner'), [
member({ email: SELF_EMAIL, role: 'owner', is_original_owner: true })
])
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.slow()
await setupCloudApp(page, workspace('team', 'member'), [
member({
email: 'creator@test.comfy.org',
role: 'owner',
is_original_owner: true
}),
member({ email: SELF_EMAIL, role: 'member' })
])
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()
})
})

View File

@@ -52,8 +52,6 @@
--color-gold-500: #fdab34;
--color-gold-600: #fd9903;
--color-credit: #fabc25;
--color-coral-500: #f75951;
--color-coral-600: #e04e48;
--color-coral-700: #b33a3a;
@@ -238,8 +236,6 @@
--secondary-background: var(--color-smoke-200);
--secondary-background-hover: var(--color-smoke-400);
--secondary-background-selected: var(--color-smoke-600);
--tertiary-background: var(--color-smoke-400);
--tertiary-background-hover: var(--color-smoke-500);
--base-background: var(--color-white);
--primary-background: var(--color-azure-400);
--primary-background-hover: var(--color-cobalt-800);
@@ -388,8 +384,6 @@
--secondary-background: var(--color-charcoal-600);
--secondary-background-hover: var(--color-charcoal-400);
--secondary-background-selected: var(--color-charcoal-200);
--tertiary-background: var(--color-charcoal-400);
--tertiary-background-hover: var(--color-charcoal-300);
--base-background: var(--color-charcoal-800);
--primary-background: var(--color-azure-600);
--primary-background-hover: var(--color-azure-400);
@@ -560,8 +554,6 @@
--color-secondary-background: var(--secondary-background);
--color-secondary-background-hover: var(--secondary-background-hover);
--color-secondary-background-selected: var(--secondary-background-selected);
--color-tertiary-background: var(--tertiary-background);
--color-tertiary-background-hover: var(--tertiary-background-hover);
--color-primary-background: var(--primary-background);
--color-primary-background-hover: var(--primary-background-hover);
--color-destructive-background: var(--destructive-background);

View File

@@ -11,8 +11,6 @@ import {
import { useI18n } from 'vue-i18n'
import { toValue } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { t } = useI18n()
defineOptions({
@@ -52,27 +50,11 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
</DropdownMenuSub>
<DropdownMenuItem
v-else
v-tooltip="
item.tooltip ? { value: String(item.tooltip), showDelay: 0 } : undefined
"
:class="
cn(
itemClass,
String(item.class ?? ''),
Boolean(item.tooltip) && toValue(item.disabled) && 'pointer-events-auto'
)
"
v-bind="
'checked' in item
? { role: 'menuitemradio', 'aria-checked': Boolean(item.checked) }
: {}
"
:class="itemClass"
:disabled="toValue(item.disabled) ?? !item.command"
@select="item.command?.({ originalEvent: $event, item })"
>
<!-- Items declaring an icon key (even empty) keep the slot so labels align
within icon-bearing menus; icon-less menus render labels flush-left. -->
<i v-if="'icon' in item" class="size-5 shrink-0" :class="item.icon" />
<i class="size-5 shrink-0" :class="item.icon" />
<div class="mr-auto truncate" v-text="item.label" />
<i v-if="item.checked" class="icon-[lucide--check] shrink-0" />
<div

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import { ZIndex } from '@primeuix/utils/zindex'
import type { MenuItem } from 'primevue/menuitem'
import {
DropdownMenuArrow,
@@ -8,16 +7,13 @@ import {
DropdownMenuRoot,
DropdownMenuTrigger
} from 'reka-ui'
import { computed, ref, toValue } from 'vue'
import { computed, toValue } from 'vue'
import DropdownItem from '@/components/common/DropdownItem.vue'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@comfyorg/tailwind-utils'
import type { ButtonVariants } from '../ui/button/button.variants'
// Shared base for @primeuix's auto-incrementing 'modal' z-index counter.
const MODAL_BASE_Z_INDEX = 1700
defineOptions({
inheritAttrs: false
})
@@ -45,20 +41,10 @@ const contentClass = computed(() =>
contentProp
)
)
// Body-portaled content keeps its static z-1700 unless a dialog that joined
// @primeuix's auto-incrementing 'modal' counter is open above it; then lift
// past that dialog so the menu isn't hidden behind it.
const open = ref(false)
const contentStyle = computed(() => {
if (!open.value) return undefined
const topZIndex = ZIndex.getCurrent('modal')
return topZIndex >= MODAL_BASE_Z_INDEX ? { zIndex: topZIndex + 1 } : undefined
})
</script>
<template>
<DropdownMenuRoot v-model:open="open">
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<slot name="button">
<Button :size="buttonSize ?? 'icon'" :class="buttonClass">
@@ -74,7 +60,6 @@ const contentStyle = computed(() => {
:collision-padding="10"
v-bind="$attrs"
:class="contentClass"
:style="contentStyle"
>
<slot :item-class>
<DropdownItem

View File

@@ -1,56 +0,0 @@
import { ZIndex } from '@primeuix/utils/zindex'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import DropdownMenu from './DropdownMenu.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function renderMenu() {
return render(DropdownMenu, {
props: { entries: [{ label: 'Item A' }] },
global: { plugins: [i18n], directives: { tooltip: {} } }
})
}
let openModal: HTMLElement | undefined
afterEach(() => {
if (openModal) {
ZIndex.clear(openModal)
openModal = undefined
}
})
describe('DropdownMenu z-index', () => {
it('opens above a dialog registered with the modal z-index counter', async () => {
openModal = document.createElement('div')
ZIndex.set('modal', openModal, 1700)
const dialogZ = Number(openModal.style.zIndex)
const user = userEvent.setup()
renderMenu()
await user.click(screen.getByRole('button'))
const menu = await screen.findByRole('menu')
expect(Number(menu.style.zIndex)).toBeGreaterThan(dialogZ)
})
it('leaves the static z-index untouched when no dialog is open', async () => {
const user = userEvent.setup()
renderMenu()
await user.click(screen.getByRole('button'))
const menu = await screen.findByRole('menu')
expect(menu.style.zIndex).toBe('')
expect(menu.className).toContain('z-1700')
})
})

View File

@@ -1,97 +0,0 @@
<template>
<div class="credits-container flex h-full flex-col gap-4">
<div>
<h2 class="mb-2 text-2xl font-bold">
{{ $t('credits.credits') }}
</h2>
<Divider />
</div>
<CreditsTile />
<div class="flex items-center justify-between">
<h3 class="m-0">{{ $t('credits.activity') }}</h3>
<Button variant="muted-textonly" @click="handleCreditsHistoryClick">
<i class="pi pi-arrow-up-right" />
{{ $t('credits.invoiceHistory') }}
</Button>
</div>
<UsageLogsTable ref="usageLogsTableRef" />
<div class="flex flex-row gap-2">
<Button variant="muted-textonly" @click="handleFaqClick">
<i class="pi pi-question-circle" />
{{ $t('credits.faqs') }}
</Button>
<Button variant="muted-textonly" @click="handleOpenPartnerNodesInfo">
<i class="pi pi-question-circle" />
{{ $t('subscription.partnerNodesCredits') }}
</Button>
<Button variant="muted-textonly" @click="handleMessageSupport">
<i class="pi pi-comments" />
{{ $t('credits.messageSupport') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import Divider from 'primevue/divider'
import { ref, watch } from 'vue'
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import CreditsTile from '@/platform/cloud/subscription/components/CreditsTile.vue'
import { useTelemetry } from '@/platform/telemetry'
import { useAuthStore } from '@/stores/authStore'
import { useCommandStore } from '@/stores/commandStore'
const { buildDocsUrl, docsPaths } = useExternalLink()
const authStore = useAuthStore()
const authActions = useAuthActions()
const commandStore = useCommandStore()
const telemetry = useTelemetry()
const usageLogsTableRef = ref<InstanceType<typeof UsageLogsTable> | null>(null)
watch(
() => authStore.lastBalanceUpdateTime,
(newTime, oldTime) => {
if (newTime && newTime !== oldTime && usageLogsTableRef.value) {
void usageLogsTableRef.value.refresh()
}
}
)
const handleCreditsHistoryClick = async () => {
await authActions.accessBillingPortal()
}
const handleMessageSupport = async () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'credits_panel'
})
await commandStore.execute('Comfy.ContactSupport')
}
const handleFaqClick = () => {
window.open(
buildDocsUrl('/tutorials/api-nodes/faq', { includeLocale: true }),
'_blank',
'noopener,noreferrer'
)
}
const handleOpenPartnerNodesInfo = () => {
window.open(
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),
'_blank',
'noopener,noreferrer'
)
}
</script>

View File

@@ -0,0 +1,195 @@
<template>
<div class="credits-container h-full">
<!-- Legacy Design -->
<div class="flex h-full flex-col">
<h2 class="mb-2 text-2xl font-bold">
{{ $t('credits.credits') }}
</h2>
<Divider />
<div class="flex flex-col gap-2">
<h3 class="text-sm font-medium text-muted">
{{ $t('credits.yourCreditBalance') }}
</h3>
<div class="flex items-center justify-between">
<UserCredit text-class="text-3xl font-bold" />
<Skeleton v-if="loading" width="2rem" height="2rem" />
<Button
v-else-if="isActiveSubscription"
:loading="loading"
@click="handlePurchaseCreditsClick"
>
{{ $t('credits.purchaseCredits') }}
</Button>
</div>
<div class="flex flex-row items-center">
<Skeleton
v-if="balanceLoading"
width="12rem"
height="1rem"
class="text-xs"
/>
<div v-else-if="formattedLastUpdateTime" class="text-xs text-muted">
{{ $t('credits.lastUpdated') }}: {{ formattedLastUpdateTime }}
</div>
<Button
variant="muted-textonly"
size="icon-sm"
:aria-label="$t('g.refresh')"
@click="() => authActions.fetchBalance()"
>
<i class="pi pi-refresh" />
</Button>
</div>
</div>
<div class="flex items-center justify-between">
<h3>{{ $t('credits.activity') }}</h3>
<Button
variant="muted-textonly"
:loading="loading"
@click="handleCreditsHistoryClick"
>
<i class="pi pi-arrow-up-right" />
{{ $t('credits.invoiceHistory') }}
</Button>
</div>
<template v-if="creditHistory.length > 0">
<div class="grow">
<DataTable :value="creditHistory" :show-headers="false">
<Column field="title" :header="$t('g.name')">
<template #body="{ data }">
<div class="text-sm font-medium">{{ data.title }}</div>
<div class="text-xs text-muted">{{ data.timestamp }}</div>
</template>
</Column>
<Column field="amount" :header="$t('g.amount')">
<template #body="{ data }">
<div
:class="[
'text-center text-base font-medium',
data.isPositive ? 'text-sky-500' : 'text-red-400'
]"
>
{{ data.isPositive ? '+' : '-' }}${{
formatMetronomeCurrency(data.amount, 'usd')
}}
</div>
</template>
</Column>
</DataTable>
</div>
</template>
<Divider />
<UsageLogsTable ref="usageLogsTableRef" />
<div class="flex flex-row gap-2">
<Button variant="muted-textonly" @click="handleFaqClick">
<i class="pi pi-question-circle" />
{{ $t('credits.faqs') }}
</Button>
<Button variant="muted-textonly" @click="handleOpenPartnerNodesInfo">
<i class="pi pi-question-circle" />
{{ $t('subscription.partnerNodesCredits') }}
</Button>
<Button variant="muted-textonly" @click="handleMessageSupport">
<i class="pi pi-comments" />
{{ $t('credits.messageSupport') }}
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
import Divider from 'primevue/divider'
import Skeleton from 'primevue/skeleton'
import { computed, ref, watch } from 'vue'
import UserCredit from '@/components/common/UserCredit.vue'
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
import { useAuthStore } from '@/stores/authStore'
import { formatMetronomeCurrency } from '@/utils/formatUtil'
interface CreditHistoryItemData {
title: string
timestamp: string
amount: number
isPositive: boolean
}
const { buildDocsUrl, docsPaths } = useExternalLink()
const dialogService = useDialogService()
const authStore = useAuthStore()
const authActions = useAuthActions()
const commandStore = useCommandStore()
const telemetry = useTelemetry()
const { isActiveSubscription } = useBillingContext()
const loading = computed(() => authStore.loading)
const balanceLoading = computed(() => authStore.isFetchingBalance)
const usageLogsTableRef = ref<InstanceType<typeof UsageLogsTable> | null>(null)
const formattedLastUpdateTime = computed(() =>
authStore.lastBalanceUpdateTime
? authStore.lastBalanceUpdateTime.toLocaleString()
: ''
)
watch(
() => authStore.lastBalanceUpdateTime,
(newTime, oldTime) => {
if (newTime && newTime !== oldTime && usageLogsTableRef.value) {
usageLogsTableRef.value.refresh()
}
}
)
const handlePurchaseCreditsClick = () => {
// Track purchase credits entry from Settings > Credits panel
useTelemetry()?.trackAddApiCreditButtonClicked()
dialogService.showTopUpCreditsDialog()
}
const handleCreditsHistoryClick = async () => {
await authActions.accessBillingPortal()
}
const handleMessageSupport = async () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'credits_panel'
})
await commandStore.execute('Comfy.ContactSupport')
}
const handleFaqClick = () => {
window.open(
buildDocsUrl('/tutorials/api-nodes/faq', { includeLocale: true }),
'_blank'
)
}
const handleOpenPartnerNodesInfo = () => {
window.open(
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),
'_blank'
)
}
const creditHistory = ref<CreditHistoryItemData[]>([])
</script>

View File

@@ -195,7 +195,10 @@ import { useWorkspaceStore } from '@/stores/workspaceStore'
import { forEachNode } from '@/utils/graphTraversalUtil'
import SelectionRectangle from './SelectionRectangle.vue'
import { useUrlActionLoaders } from '@/composables/useUrlActionLoaders'
import { isCloud } from '@/platform/distribution/types'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useCreateWorkspaceUrlLoader } from '@/platform/workspace/composables/useCreateWorkspaceUrlLoader'
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
const { t } = useI18n()
const emit = defineEmits<{
@@ -454,7 +457,10 @@ useEventListener(
const comfyAppReady = ref(false)
const workflowPersistence = useWorkflowPersistence()
const { runUrlActionLoaders } = useUrlActionLoaders()
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
useCanvasDrop(canvasRef)
useLitegraphSettings()
useNodeBadge()
@@ -563,8 +569,23 @@ onMounted(async () => {
() => canvasStore.updateSelectedItems()
)
// Run query-param deep-link loaders (?invite, ?create_workspace, ?pricing)
await runUrlActionLoaders()
// Accept workspace invite from URL if present (e.g., ?invite=TOKEN)
// WorkspaceAuthGate ensures flag state is resolved before GraphCanvas mounts
if (inviteUrlLoader && flags.teamWorkspacesEnabled) {
await inviteUrlLoader.loadInviteFromUrl()
}
// Open create workspace dialog from URL if present (e.g., ?create_workspace=1)
if (createWorkspaceUrlLoader && flags.teamWorkspacesEnabled) {
try {
await createWorkspaceUrlLoader.loadCreateWorkspaceFromUrl()
} catch (error) {
console.error(
'[GraphCanvas] Failed to load create workspace from URL:',
error
)
}
}
// Initialize release store to fetch releases from comfy-api (fire-and-forget)
const { useReleaseStore } =

View File

@@ -26,6 +26,7 @@ const singleErrorCard: ErrorCardData = {
title: 'CLIPTextEncode',
nodeId: createNodeExecutionId([10]),
nodeTitle: 'CLIP Text Encode (Prompt)',
isSubgraphNode: false,
errors: [
{
message: 'Required input "text" is missing.',
@@ -39,6 +40,7 @@ const multipleErrorsCard: ErrorCardData = {
title: 'VAEDecode',
nodeId: createNodeExecutionId([24]),
nodeTitle: 'VAE Decode',
isSubgraphNode: false,
errors: [
{
message: 'Required input "samples" is missing.',
@@ -56,6 +58,7 @@ const runtimeErrorCard: ErrorCardData = {
title: 'KSampler',
nodeId: createNodeExecutionId([45]),
nodeTitle: 'KSampler',
isSubgraphNode: false,
errors: [
{
message: 'OutOfMemoryError: CUDA out of memory. Tried to allocate 1.2GB.',
@@ -70,6 +73,20 @@ const runtimeErrorCard: ErrorCardData = {
]
}
const subgraphErrorCard: ErrorCardData = {
id: 'node-3:15',
title: 'KSampler',
nodeId: createNodeExecutionId([3, 15]),
nodeTitle: 'Nested KSampler',
isSubgraphNode: true,
errors: [
{
message: 'Latent input is required.',
details: ''
}
]
}
const promptOnlyCard: ErrorCardData = {
id: '__prompt__',
title: 'Prompt has no outputs.',
@@ -87,6 +104,13 @@ export const SingleValidationError: Story = {
}
}
/** Subgraph node error — shows "Enter subgraph" button */
export const WithEnterSubgraphButton: Story = {
args: {
card: subgraphErrorCard
}
}
/** Multiple validation errors on one node */
export const MultipleErrors: Story = {
args: {

View File

@@ -79,6 +79,7 @@ describe('ErrorNodeCard.vue', () => {
},
rightSidePanel: {
locateNode: 'Locate Node',
enterSubgraph: 'Enter Subgraph',
errorLog: 'Error log',
findOnGithubTooltip: 'Search GitHub issues for related problems',
getHelpTooltip:

View File

@@ -21,6 +21,15 @@
</span>
</span>
<div class="flex shrink-0 items-center">
<Button
v-if="card.isSubgraphNode"
variant="secondary"
size="sm"
class="shrink-0 focus-visible:ring-inset"
@click.stop="handleEnterSubgraph"
>
{{ t('rightSidePanel.enterSubgraph') }}
</Button>
<Button
v-if="hasRuntimeError"
variant="textonly"
@@ -193,6 +202,7 @@ const { card, compact = false } = defineProps<{
const emit = defineEmits<{
locateNode: [nodeId: string]
enterSubgraph: [nodeId: string]
copyToClipboard: [text: string]
}>()
@@ -223,6 +233,12 @@ function handleLocateNode() {
}
}
function handleEnterSubgraph() {
if (card.nodeId) {
emit('enterSubgraph', card.nodeId)
}
}
function handleCopyError(idx: number) {
const details = displayedDetailsMap.value[idx]
const message = getCopyMessage(card.errors[idx])

View File

@@ -11,6 +11,7 @@ import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { MissingNodeType } from '@/types/comfy'
const mockFocusNode = vi.hoisted(() => vi.fn())
const mockEnterSubgraph = vi.hoisted(() => vi.fn())
vi.mock('@/scripts/app', () => ({
app: {
@@ -34,9 +35,16 @@ vi.mock('@/composables/useCopyToClipboard', () => ({
}))
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: vi.fn(() => ({
fitView: vi.fn()
}))
}))
vi.mock('@/composables/canvas/useFocusNode', () => ({
useFocusNode: vi.fn(() => ({
focusNode: mockFocusNode
focusNode: mockFocusNode,
enterSubgraph: mockEnterSubgraph
}))
}))

View File

@@ -249,6 +249,7 @@
:card="card"
:compact="isSingleNodeSelected"
@locate-node="handleLocateNode"
@enter-subgraph="handleEnterSubgraph"
@copy-to-clipboard="copyToClipboard"
/>
</div>
@@ -356,7 +357,7 @@ const ErrorPanelSurveyCta =
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const { focusNode } = useFocusNode()
const { focusNode, enterSubgraph } = useFocusNode()
const { openGitHubIssues, contactSupport } = useErrorActions()
const rightSidePanelStore = useRightSidePanelStore()
const missingModelStore = useMissingModelStore()
@@ -522,4 +523,8 @@ function handleReplaceGroup(group: SwapNodeGroup) {
function handleReplaceAll() {
replaceAllGroups(swapNodeGroups.value)
}
function handleEnterSubgraph(nodeId: string) {
enterSubgraph(nodeId, errorNodeCache.value)
}
</script>

View File

@@ -16,6 +16,7 @@ export interface ErrorCardData {
nodeId?: NodeExecutionId
nodeTitle?: string
graphNodeId?: string
isSubgraphNode?: boolean
errors: ErrorItem[]
}

View File

@@ -671,6 +671,30 @@ describe('useErrorGroups', () => {
expect(nodeIds).toEqual(['1', '2', '10'])
})
it('marks only nested execution paths as subgraph node cards', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
},
'1:20': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
}
}
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution'
)
expect(execGroup?.cards).toMatchObject([
{ nodeId: '1', isSubgraphNode: false },
{ nodeId: '1:20', isSubgraphNode: true }
])
})
it('sorts cards with subpath nodeIds before higher root IDs', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {

View File

@@ -130,6 +130,7 @@ function createErrorCard(
nodeId,
nodeTitle: nodeInfo.title,
graphNodeId: nodeInfo.graphNodeId,
isSubgraphNode: nodeId.includes(':'),
errors: []
}
}

View File

@@ -22,8 +22,6 @@ export const buttonVariants = cva({
link: 'bg-transparent text-muted-foreground hover:text-base-foreground',
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90',
base: 'bg-base-background text-base-foreground hover:bg-secondary-background-hover',
tertiary:
'bg-tertiary-background text-base-foreground hover:bg-tertiary-background-hover',
gradient:
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90'
},
@@ -56,7 +54,6 @@ const variants = [
'destructive-textonly',
'link',
'base',
'tertiary',
'overlay-white',
'gradient'
] as const satisfies Array<ButtonVariants['variant']>

View File

@@ -13,8 +13,7 @@ import { cn } from '@comfyorg/tailwind-utils'
import Slider from '@/components/ui/slider/Slider.vue'
import {
DEFAULT_TEAM_PLAN_STOP_INDEX,
TEAM_PLAN_CREDIT_STOPS,
getStopDiscountedMonthlyUsd
TEAM_PLAN_CREDIT_STOPS
} from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
import type { CreditStop } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
@@ -84,7 +83,7 @@ const effectiveDiscountPercent = computed(() =>
: current.value.discountPercentYearly
)
const discountedMonthly = computed(() =>
getStopDiscountedMonthlyUsd(current.value, cycle)
Math.round(current.value.usd * (1 - effectiveDiscountPercent.value / 100))
)
const saveAmount = computed(() => current.value.usd - discountedMonthly.value)
const hasDiscount = computed(() => effectiveDiscountPercent.value > 0)

View File

@@ -7,9 +7,7 @@ import type {
CreateTopupResponse,
CurrentTeamCreditStop,
Plan,
PreviewSubscribeOptions,
PreviewSubscribeResponse,
SubscribeOptions,
SubscribeResponse,
SubscriptionDuration,
SubscriptionTier,
@@ -23,9 +21,9 @@ export interface SubscriptionInfo {
tier: SubscriptionTier | null
duration: SubscriptionDuration | null
planSlug: string | null
/** ISO 8601; format at the display site. */
/** ISO 8601 */
renewalDate: string | null
/** ISO 8601; format at the display site. */
/** ISO 8601 */
endDate: string | null
isCancelled: boolean
hasFunds: boolean
@@ -45,27 +43,16 @@ export interface BillingActions {
fetchBalance: () => Promise<void>
subscribe: (
planSlug: string,
options?: SubscribeOptions
returnUrl?: string,
cancelUrl?: string
) => Promise<SubscribeResponse | void>
previewSubscribe: (
planSlug: string,
options?: PreviewSubscribeOptions
planSlug: string
) => Promise<PreviewSubscribeResponse | null>
manageSubscription: () => Promise<void>
cancelSubscription: () => Promise<void>
/**
* Reactivates a cancelled-but-still-active subscription. Legacy has no
* dedicated endpoint, so the legacy adapter re-runs the checkout flow.
* The workspace adapter refreshes status and balance internally on success.
*/
resubscribe: () => Promise<void>
/**
* Purchases additional credits. Standardized on **whole-dollar cents**
* (multiples of 100); the legacy adapter divides by 100 for the
* dollar-based /customers/credit endpoint.
* Pass-through by design: the caller owns the completed/pending follow-up
* (balance refresh or billing-op polling), so this does not refresh.
*/
/** `amountCents` must be a whole-dollar multiple of 100. */
topup: (amountCents: number) => Promise<CreateTopupResponse | void>
fetchPlans: () => Promise<void>
/**
@@ -93,11 +80,8 @@ export interface BillingState {
isLoading: Ref<boolean>
error: Ref<string | null>
isActiveSubscription: ComputedRef<boolean>
/** Reflects the active workspace's tier, not the user's personal tier. */
isFreeTier: ComputedRef<boolean>
/** Coarse funding state (`billing_status`); legacy reports null. */
billingStatus: ComputedRef<BillingStatus | null>
/** Lifecycle state; legacy synthesizes it from active/cancelled flags. */
subscriptionStatus: ComputedRef<BillingSubscriptionStatus | null>
tier: ComputedRef<SubscriptionTier | null>
renewalDate: ComputedRef<string | null>

View File

@@ -1,8 +1,6 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import type {
BillingStatusResponse,
Plan
@@ -22,14 +20,12 @@ const {
mockIsPersonal,
mockPlans,
mockPurchaseCredits,
mockUpdateActiveWorkspace,
mockBillingStatus
} = vi.hoisted(() => ({
mockTeamWorkspacesEnabled: { value: false },
mockIsPersonal: { value: true },
mockPlans: { value: [] as Plan[] },
mockPurchaseCredits: vi.fn(),
mockUpdateActiveWorkspace: vi.fn(),
mockBillingStatus: {
value: {
is_active: true,
@@ -48,25 +44,15 @@ vi.mock('@vueuse/core', async (importOriginal) => {
}
})
vi.mock('@/composables/useFeatureFlags', async () => {
const { ref } = await import('vue')
const teamWorkspacesEnabledRef = ref(mockTeamWorkspacesEnabled.value)
Object.defineProperty(mockTeamWorkspacesEnabled, 'value', {
get: () => teamWorkspacesEnabledRef.value,
set: (value: boolean) => {
teamWorkspacesEnabledRef.value = value
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get teamWorkspacesEnabled() {
return mockTeamWorkspacesEnabled.value
}
}
})
return {
useFeatureFlags: () => ({
flags: {
get teamWorkspacesEnabled() {
return mockTeamWorkspacesEnabled.value
}
}
})
}
})
}))
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
@@ -78,7 +64,7 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
? { id: 'personal-123', type: 'personal' }
: { id: 'team-456', type: 'team' }
},
updateActiveWorkspace: mockUpdateActiveWorkspace
updateActiveWorkspace: vi.fn()
})
}))
@@ -156,28 +142,11 @@ describe('useBillingContext', () => {
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
})
it('selects legacy type when team workspaces are disabled', () => {
mockTeamWorkspacesEnabled.value = false
it('returns legacy type for personal workspace', () => {
const { type } = useBillingContext()
expect(type.value).toBe('legacy')
})
it('selects workspace type for personal when team workspaces are enabled', () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = true
const { type } = useBillingContext()
expect(type.value).toBe('workspace')
})
it('selects workspace type for team when team workspaces are enabled', () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
const { type } = useBillingContext()
expect(type.value).toBe('workspace')
})
it('provides subscription info from legacy billing', () => {
const { subscription } = useBillingContext()
@@ -237,14 +206,6 @@ describe('useBillingContext', () => {
expect(mockPurchaseCredits).toHaveBeenCalledWith(5)
})
it('rejects topup amounts that are not positive whole-dollar cents', async () => {
const { topup } = useBillingContext()
await expect(topup(550)).rejects.toThrow()
await expect(topup(0)).rejects.toThrow()
await expect(topup(-100)).rejects.toThrow()
await expect(topup(99.5)).rejects.toThrow()
})
it('provides isActiveSubscription convenience computed', () => {
const { isActiveSubscription } = useBillingContext()
expect(isActiveSubscription.value).toBe(true)
@@ -260,42 +221,6 @@ describe('useBillingContext', () => {
expect(() => showSubscriptionDialog()).not.toThrow()
})
it('reinitializes workspace billing when the type flips on after legacy init', async () => {
mockTeamWorkspacesEnabled.value = false
mockIsPersonal.value = true
const { type, initialize } = useBillingContext()
await initialize()
await nextTick()
expect(type.value).toBe('legacy')
expect(workspaceApi.getBillingStatus).not.toHaveBeenCalled()
// Authenticated remote config resolves the flag on for the same workspace
mockTeamWorkspacesEnabled.value = true
await vi.waitFor(() => {
expect(type.value).toBe('workspace')
expect(workspaceApi.getBillingStatus).toHaveBeenCalled()
})
})
describe('subscription mirror to workspace store', () => {
it('mirrors subscription for personal workspaces when team workspaces are enabled', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = true
const { initialize } = useBillingContext()
await initialize()
await nextTick()
expect(mockUpdateActiveWorkspace).toHaveBeenCalledWith({
isSubscribed: true,
subscriptionPlan: null
})
})
})
describe('getMaxSeats', () => {
it('returns 1 for personal workspaces regardless of tier', () => {
const { getMaxSeats } = useBillingContext()

View File

@@ -7,10 +7,6 @@ import {
getTierFeatures
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type {
PreviewSubscribeOptions,
SubscribeOptions
} from '@/platform/workspace/api/workspaceApi'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import type {
@@ -31,11 +27,11 @@ import { useWorkspaceBilling } from '@/platform/workspace/composables/useWorkspa
const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
/**
* Unified billing context that selects the billing implementation by build/flag.
* Unified billing context that automatically switches between legacy (user-scoped)
* and workspace billing based on the active workspace type.
*
* - Team workspaces disabled (OSS/Desktop): legacy billing via /customers/*
* - Team workspaces enabled: workspace billing via /api/billing/* for both
* personal (single-seat workspace) and team workspaces
* - Personal workspaces use legacy billing via /customers/* endpoints
* - Team workspaces use workspace billing via /billing/* endpoints
*
* The context automatically initializes when the workspace changes and provides
* a unified interface for subscription status, balance, and billing actions.
@@ -96,14 +92,16 @@ function useBillingContextInternal(): BillingContext {
const error = ref<string | null>(null)
/**
* Determines which billing type to use, keyed only on the build/flag:
* - Team workspaces feature disabled (OSS/Desktop): legacy (/customers)
* - Team workspaces feature enabled: workspace (/api/billing), for both
* personal (single-seat workspace) and team workspaces
* Determines which billing type to use:
* - If team workspaces feature is disabled: always use legacy (/customers)
* - If team workspaces feature is enabled:
* - Personal workspace: use legacy (/customers)
* - Team workspace: use workspace (/billing)
*/
const type = computed<BillingType>(() =>
flags.teamWorkspacesEnabled ? 'workspace' : 'legacy'
)
const type = computed<BillingType>(() => {
if (!flags.teamWorkspacesEnabled) return 'legacy'
return store.isInPersonalWorkspace ? 'legacy' : 'workspace'
})
const activeContext = computed(() =>
type.value === 'legacy' ? getLegacyBilling() : getWorkspaceBilling()
@@ -175,7 +173,7 @@ function useBillingContextInternal(): BillingContext {
watch(
subscription,
(sub) => {
if (!sub) return
if (!sub || store.isInPersonalWorkspace) return
store.updateActiveWorkspace({
isSubscribed: sub.isActive && !sub.isCancelled,
@@ -185,28 +183,26 @@ function useBillingContextInternal(): BillingContext {
{ immediate: true }
)
function resetBillingState() {
isInitialized.value = false
error.value = null
}
// type can flip after setup when the team-workspaces flag resolves from
// authenticated config, swapping the active backend; a fresh init is needed.
// The watch fires only when id or type actually changes, so any fire with a
// workspace selected warrants a reinit.
// Initialize billing when workspace changes
watch(
[() => store.activeWorkspace?.id, () => type.value],
async ([newWorkspaceId]) => {
() => store.activeWorkspace?.id,
async (newWorkspaceId, oldWorkspaceId) => {
if (!newWorkspaceId) {
resetBillingState()
// No workspace selected - reset state
isInitialized.value = false
error.value = null
return
}
isInitialized.value = false
try {
await initialize()
} catch (err) {
console.error('Failed to initialize billing context:', err)
if (newWorkspaceId !== oldWorkspaceId) {
// Workspace changed - reinitialize
isInitialized.value = false
try {
await initialize()
} catch (err) {
// Error is already captured in error ref
console.error('Failed to initialize billing context:', err)
}
}
},
{ immediate: true }
@@ -237,15 +233,16 @@ function useBillingContextInternal(): BillingContext {
return activeContext.value.fetchBalance()
}
async function subscribe(planSlug: string, options?: SubscribeOptions) {
return activeContext.value.subscribe(planSlug, options)
async function subscribe(
planSlug: string,
returnUrl?: string,
cancelUrl?: string
) {
return activeContext.value.subscribe(planSlug, returnUrl, cancelUrl)
}
async function previewSubscribe(
planSlug: string,
options?: PreviewSubscribeOptions
) {
return activeContext.value.previewSubscribe(planSlug, options)
async function previewSubscribe(planSlug: string) {
return activeContext.value.previewSubscribe(planSlug)
}
async function manageSubscription() {
@@ -261,15 +258,6 @@ function useBillingContextInternal(): BillingContext {
}
async function topup(amountCents: number) {
if (
!Number.isInteger(amountCents) ||
amountCents <= 0 ||
amountCents % 100 !== 0
) {
throw new Error(
'Top-up amount must be a positive whole-dollar cent value'
)
}
return activeContext.value.topup(amountCents)
}

View File

@@ -5,9 +5,7 @@ import { useSubscription } from '@/platform/cloud/subscription/composables/useSu
import type {
BillingStatus,
BillingSubscriptionStatus,
PreviewSubscribeOptions,
PreviewSubscribeResponse,
SubscribeOptions,
SubscribeResponse
} from '@/platform/workspace/api/workspaceApi'
import { useAuthStore } from '@/stores/authStore'
@@ -149,15 +147,15 @@ export function useLegacyBilling(): BillingState & BillingActions {
async function subscribe(
_planSlug: string,
_options?: SubscribeOptions
_returnUrl?: string,
_cancelUrl?: string
): Promise<SubscribeResponse | void> {
// Legacy billing uses Stripe checkout flow via useSubscription
await legacySubscribe()
}
async function previewSubscribe(
_planSlug: string,
_options?: PreviewSubscribeOptions
_planSlug: string
): Promise<PreviewSubscribeResponse | null> {
// Legacy billing doesn't support preview - returns null
return null

View File

@@ -8,6 +8,7 @@ import type {
Subgraph
} from '@/lib/litegraph/src/litegraph'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { useLitegraphService } from '@/services/litegraphService'
async function navigateToGraph(targetGraph: LGraph) {
const canvasStore = useCanvasStore()
@@ -48,7 +49,23 @@ export function useFocusNode() {
canvasStore.canvas?.animateToBounds(graphNode.boundingRect)
}
async function enterSubgraph(
nodeId: string,
executionIdMap?: Map<string, LGraphNode>
) {
if (!canvasStore.canvas) return
const graphNode = executionIdMap
? executionIdMap.get(nodeId)
: getNodeByExecutionId(app.rootGraph, nodeId)
if (!graphNode?.graph) return
await navigateToGraph(graphNode.graph as LGraph)
useLitegraphService().fitView()
}
return {
focusNode
focusNode,
enterSubgraph
}
}

View File

@@ -1,96 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useUrlActionLoaders } from './useUrlActionLoaders'
const mockIsCloud = vi.hoisted(() => ({ value: true }))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud.value
}
}))
const mockFlags = vi.hoisted(() => ({ value: { teamWorkspacesEnabled: true } }))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({ flags: mockFlags.value })
}))
const mocks = vi.hoisted(() => ({
loadInvite: vi.fn().mockResolvedValue(undefined),
loadCreateWorkspace: vi.fn().mockResolvedValue(undefined),
loadPricingTable: vi.fn().mockResolvedValue(undefined),
useInvite: vi.fn(),
useCreateWorkspace: vi.fn(),
usePricingTable: vi.fn()
}))
mocks.useInvite.mockImplementation(() => ({
loadInviteFromUrl: mocks.loadInvite
}))
mocks.useCreateWorkspace.mockImplementation(() => ({
loadCreateWorkspaceFromUrl: mocks.loadCreateWorkspace
}))
mocks.usePricingTable.mockImplementation(() => ({
loadPricingTableFromUrl: mocks.loadPricingTable
}))
vi.mock('@/platform/workspace/composables/useInviteUrlLoader', () => ({
useInviteUrlLoader: mocks.useInvite
}))
vi.mock('@/platform/workspace/composables/useCreateWorkspaceUrlLoader', () => ({
useCreateWorkspaceUrlLoader: mocks.useCreateWorkspace
}))
vi.mock(
'@/platform/cloud/subscription/composables/usePricingTableUrlLoader',
() => ({ usePricingTableUrlLoader: mocks.usePricingTable })
)
describe('useUrlActionLoaders', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCloud.value = true
mockFlags.value = { teamWorkspacesEnabled: true }
})
it('does not instantiate or run any loader off cloud', async () => {
mockIsCloud.value = false
const { runUrlActionLoaders } = useUrlActionLoaders()
await runUrlActionLoaders()
expect(mocks.useInvite).not.toHaveBeenCalled()
expect(mocks.useCreateWorkspace).not.toHaveBeenCalled()
expect(mocks.usePricingTable).not.toHaveBeenCalled()
expect(mocks.loadInvite).not.toHaveBeenCalled()
expect(mocks.loadCreateWorkspace).not.toHaveBeenCalled()
expect(mocks.loadPricingTable).not.toHaveBeenCalled()
})
it('runs all loaders on cloud when team workspaces are enabled', async () => {
const { runUrlActionLoaders } = useUrlActionLoaders()
await runUrlActionLoaders()
expect(mocks.loadInvite).toHaveBeenCalledOnce()
expect(mocks.loadCreateWorkspace).toHaveBeenCalledOnce()
expect(mocks.loadPricingTable).toHaveBeenCalledOnce()
})
it('runs the pricing loader but skips the flag-gated loaders when team workspaces are disabled', async () => {
mockFlags.value = { teamWorkspacesEnabled: false }
const { runUrlActionLoaders } = useUrlActionLoaders()
await runUrlActionLoaders()
expect(mocks.loadInvite).not.toHaveBeenCalled()
expect(mocks.loadCreateWorkspace).not.toHaveBeenCalled()
expect(mocks.loadPricingTable).toHaveBeenCalledOnce()
})
it('isolates a pricing-loader failure so it does not abort the boot chain', async () => {
mocks.loadPricingTable.mockRejectedValueOnce(new Error('boom'))
const { runUrlActionLoaders } = useUrlActionLoaders()
await expect(runUrlActionLoaders()).resolves.toBeUndefined()
expect(mocks.loadInvite).toHaveBeenCalledOnce()
expect(mocks.loadCreateWorkspace).toHaveBeenCalledOnce()
})
})

View File

@@ -1,55 +0,0 @@
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { usePricingTableUrlLoader } from '@/platform/cloud/subscription/composables/usePricingTableUrlLoader'
import { isCloud } from '@/platform/distribution/types'
import { useCreateWorkspaceUrlLoader } from '@/platform/workspace/composables/useCreateWorkspaceUrlLoader'
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
/**
* Aggregates the query-param "deep link" loaders the cloud app checks on mount
* (`?invite`, `?create_workspace`, `?pricing`). The loaders are instantiated in
* setup so their `useRoute`/`useRouter` resolve; call `runUrlActionLoaders()`
* from `onMounted` once the app is ready.
*/
export function useUrlActionLoaders() {
const { flags } = useFeatureFlags()
const inviteUrlLoader = isCloud ? useInviteUrlLoader() : null
const createWorkspaceUrlLoader = isCloud
? useCreateWorkspaceUrlLoader()
: null
const pricingTableUrlLoader = isCloud ? usePricingTableUrlLoader() : null
async function runUrlActionLoaders() {
// Accept workspace invite from URL if present (e.g., ?invite=TOKEN).
// WorkspaceAuthGate ensures flag state is resolved before the app mounts.
if (inviteUrlLoader && flags.teamWorkspacesEnabled) {
await inviteUrlLoader.loadInviteFromUrl()
}
// Open create workspace dialog from URL if present (e.g., ?create_workspace=1).
if (createWorkspaceUrlLoader && flags.teamWorkspacesEnabled) {
try {
await createWorkspaceUrlLoader.loadCreateWorkspaceFromUrl()
} catch (error) {
console.error(
'[UrlActionLoaders] Failed to load create workspace from URL:',
error
)
}
}
// 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(
'[UrlActionLoaders] Failed to load pricing table from URL:',
error
)
}
}
}
return { runUrlActionLoaders }
}

View File

@@ -2514,7 +2514,6 @@
"resubscribe": "Resubscribe",
"resubscribeTo": "Resubscribe to {plan}",
"resubscribeSuccess": "Subscription reactivated successfully",
"subscribeFailed": "Failed to subscribe",
"canceledCard": {
"title": "Your subscription has been canceled",
"description": "You won't be charged again. Your features remain active until {date}."
@@ -2554,23 +2553,6 @@
"creditsRemainingThisMonth": "Included (Refills {date})",
"creditsRemainingThisYear": "Included (Refills {date})",
"creditsYouveAdded": "Additional",
"remaining": "remaining",
"refillsDate": "Refills {date}",
"refillsNextCycle": "Refills next cycle",
"creditsUsed": "{used} used",
"creditsLeftOfTotal": "{remaining} left of {total}",
"monthlyUsageProgress": "{used} of {total} monthly credits used",
"additionalCreditsInfo": "About additional credits",
"additionalCredits": "Additional credits",
"additionalCreditsInUse": "In use",
"usedAfterMonthly": "Used after monthly runs out",
"monthlyCreditsUsedUpTitle": "Monthly credits are used up. Refills {date}",
"monthlyCreditsUsedUpTitleNoDate": "Monthly credits are used up",
"monthlyCreditsUsedUpDescription": "You're now spending additional credits.",
"outOfCreditsTitle": "You're out of credits. Credits refill {date}",
"outOfCreditsTitleNoDate": "You're out of credits",
"outOfCreditsDescription": "Add more credits to continue generating.",
"additionalCreditsTooltip": "Credits you add on top of your plan. Used after monthly credits run out. Each expires one year after purchase.",
"monthlyCreditsInfo": "These credits refresh monthly and don't roll over",
"viewMoreDetailsPlans": "View more details about plans & pricing",
"nextBillingCycle": "next billing cycle",
@@ -2668,9 +2650,9 @@
"perkProjectAssets": "Project & asset management",
"cta": "Subscribe to Team Yearly",
"ctaMonthly": "Subscribe to Team Monthly",
"unavailable": "This team plan is not available right now.",
"changePlan": "Change plan",
"currentPlan": "Current plan"
"currentPlan": "Current plan",
"checkoutComingSoon": "Team plan checkout is coming soon."
},
"enterprise": {
"name": "Enterprise",
@@ -2739,11 +2721,10 @@
"preview": {
"confirmPayment": "Confirm your payment",
"confirmPlanChange": "Confirm your plan change",
"startingToday": "Starts today",
"startingToday": "Starting today",
"starting": "Starting {date}",
"ends": "Ends {date}",
"eachMonthCreditsRefill": "Each month credits refill to",
"eachYearCreditsRefill": "Each year credits refill to",
"everyMonthStarting": "Every month starting {date}",
"creditsRefillTo": "Credits refill to",
"youllBeCharged": "You'll be charged",
@@ -2754,24 +2735,6 @@
"proratedCharge": "Prorated charge for {plan}",
"totalDueToday": "Total due today",
"nextPaymentDue": "Next payment due {date}. Cancel anytime.",
"confirmUpgradeTitle": "Confirm your upgrade",
"confirmUpgradeCta": "Confirm upgrade",
"confirmChange": "Confirm change",
"confirmChangeTitle": "Review your scheduled change",
"paymentPopupBlocked": "Couldn't open the payment page — please allow popups and try again.",
"switchesToday": "Switches today",
"startsOn": "Starts {date}",
"yearlySubscription": "Yearly subscription",
"newMonthlySubscription": "New monthly subscription",
"creditFromCurrent": "Credit from current {plan}",
"currentMonthly": "monthly plan",
"commitment": "commitment",
"creditsYoullGetToday": "Credits you'll get today",
"refillReplacesNote": "Replaces your monthly refill. Existing balance is kept.",
"afterThat": "After that",
"creditsRefillMonthlyTo": "Credits refill monthly to",
"billedEachMonth": "{amount} billed each month. Cancel anytime.",
"stayOnUntil": "You'll stay on {plan} until {date}.",
"termsAgreement": "By continuing, you agree to Comfy Org's {terms} and {privacy}.",
"terms": "Terms",
"privacyPolicy": "Privacy Policy",
@@ -2783,12 +2746,8 @@
},
"success": {
"allSet": "You're all set",
"inviteEmailsPlaceholder": "Enter emails separated by commas",
"inviteSubtext": "You can also invite people later from Settings",
"inviteTitle": "Invite your team",
"planUpdated": "Your plan has been successfully updated.",
"receiptEmailed": "A receipt has been emailed to you.",
"sendInvites": "Send invites"
"receiptEmailed": "A receipt has been emailed to you."
}
},
"userSettings": {
@@ -2804,7 +2763,7 @@
"workspacePanel": {
"invite": "Invite",
"inviteMember": "Invite member",
"inviteLimitReached": "You've reached the maximum of {count} members",
"inviteLimitReached": "You've reached the maximum of 50 members",
"tabs": {
"dashboard": "Dashboard",
"planCredits": "Plan & Credits",
@@ -2814,8 +2773,7 @@
"placeholder": "Dashboard workspace settings"
},
"members": {
"header": "Members",
"membersCount": "{count} of {maxSeats} members",
"membersCount": "{count}/{maxSeats} Members",
"pendingInvitesCount": "{count} pending invite | {count} pending invites",
"tabs": {
"active": "Active",
@@ -2824,30 +2782,26 @@
"columns": {
"inviteDate": "Invite date",
"expiryDate": "Expiry date",
"role": "Role"
"joinDate": "Join date"
},
"actions": {
"resendInvite": "Resend invite",
"cancelInvite": "Cancel invite",
"changeRole": "Change role",
"copyLink": "Copy invite link",
"revokeInvite": "Revoke invite",
"removeMember": "Remove member"
},
"upsellBanner": "To add teammates, upgrade your plan.",
"upsellBannerReactivate": "To add more teammates, reactivate your plan.",
"upgradeToTeam": "Upgrade to Team",
"reactivateTeam": "Reactivate Team",
"needMoreMembers": "Need more members?",
"contactUs": "Contact us",
"upsellBannerSubscribe": "Subscribe to the Creator plan or above to invite team members to this workspace.",
"upsellBannerUpgrade": "Upgrade to the Creator plan or above to invite additional team members.",
"viewPlans": "View plans",
"noInvites": "No pending invites",
"noMembers": "No members",
"searchPlaceholder": "Search..."
"personalWorkspaceMessage": "You can't invite other members to your personal workspace right now. To add members to a workspace,",
"createNewWorkspace": "create a new one."
},
"menu": {
"editWorkspace": "Edit workspace details",
"leaveWorkspace": "Leave Workspace",
"deleteWorkspace": "Delete Workspace",
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first",
"creatorCannotLeave": "The workspace creator can't leave the workspace they created"
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first"
},
"editWorkspaceDialog": {
"title": "Edit workspace details",
@@ -2871,38 +2825,32 @@
"success": "Member removed",
"error": "Failed to remove member"
},
"changeRoleDialog": {
"promoteTitle": "Make {name} an owner?",
"promoteIntro": "They'll be able to:",
"promotePermissionCredits": "Add additional credits",
"promotePermissionManage": "Manage members, payment methods, and workspace settings",
"promotePermissionRoles": "Promote and demote other owners (except the workspace creator).",
"promoteConfirm": "Make owner",
"demoteTitle": "Demote {name} to member?",
"demoteMessage": "They'll lose admin access.",
"demoteConfirm": "Demote to member",
"success": "Role updated",
"error": "Failed to update role"
},
"revokeInviteDialog": {
"title": "Uninvite this person?",
"message": "This member won't be able to join your workspace anymore. Their invite link will be invalidated.",
"revoke": "Uninvite"
},
"inviteUpsellDialog": {
"titleNotSubscribed": "A Team plan is required to invite members",
"titleNotSubscribed": "A subscription is required to invite members",
"titleSingleSeat": "Your current plan supports a single seat",
"messageNotSubscribed": "To add teammates to this workspace, upgrade to a Team plan.",
"messageSingleSeat": "Your current plan includes one seat for the workspace owner. To add teammates, upgrade to a Team plan.",
"upgradeToTeam": "Upgrade to Team"
"messageNotSubscribed": "To add team members to this workspace, you need a Creator plan or above. The Standard plan supports only a single seat (the owner).",
"messageSingleSeat": "The Standard plan includes one seat for the workspace owner. To invite additional members, upgrade to the Creator plan or above to unlock multiple seats.",
"viewPlans": "View Plans",
"upgradeToCreator": "Upgrade to Creator"
},
"inviteMemberDialog": {
"title": "Invite members to this workspace",
"placeholder": "Enter emails separated by commas",
"invalidEmailCount": "{count} invalid email address | {count} invalid email addresses",
"failedCount": "Couldn't send {count} invite. Try again. | Couldn't send {count} invites. Try again.",
"invitedMessage": "An invite was sent to {emails} | Invites were sent to {emails}",
"seatLimitReached": "You can invite up to {count} teammate. | You can invite up to {count} teammates."
"title": "Invite a person to this workspace",
"message": "Create a shareable invite link to send to someone",
"placeholder": "Enter the person's email",
"createLink": "Create link",
"linkStep": {
"title": "Send this link to the person",
"message": "Make sure their account uses this email.",
"copyLink": "Copy Link",
"done": "Done"
},
"linkCopied": "Copied",
"linkCopyFailed": "Failed to copy link"
},
"createWorkspaceDialog": {
"title": "Create a new workspace",
@@ -2929,8 +2877,6 @@
"title": "Left workspace",
"message": "You have left the workspace."
},
"inviteResent": "Invite resent",
"inviteResendFailed": "Failed to resend invite",
"failedToUpdateWorkspace": "Failed to update workspace",
"failedToCreateWorkspace": "Failed to create workspace",
"failedToDeleteWorkspace": "Failed to delete workspace",
@@ -3841,6 +3787,7 @@
"errorLog": "Error log",
"findOnGithubTooltip": "Search GitHub issues for related problems",
"getHelpTooltip": "Report this error and we'll help you resolve it",
"enterSubgraph": "Enter subgraph",
"seeError": "See Error",
"errorHelp": "For more help, {github} or {support}",
"errorHelpGithub": "submit a GitHub issue",

View File

@@ -1,377 +0,0 @@
import type { AxiosAdapter } from 'axios'
import axios, { AxiosError } from 'axios'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
attachUnifiedRemintInterceptor,
fetchWithUnifiedRemint
} from '@/platform/auth/unified/remintRetry'
const { mockRemint, flagState } = vi.hoisted(() => ({
mockRemint: vi.fn(),
flagState: { unifiedCloudAuthEnabled: true }
}))
vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({
useWorkspaceAuthStore: () => ({ remintUnifiedOnce: mockRemint })
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get unifiedCloudAuthEnabled() {
return flagState.unifiedCloudAuthEnabled
}
}
})
}))
// The axios interceptor gates on shouldRemintCloudRequest(), which is a no-op
// off-cloud; the unit env is not a cloud build, so force it on.
vi.mock('@/platform/distribution/types', () => ({ isCloud: true }))
describe('fetchWithUnifiedRemint', () => {
const ok = { status: 200 } as Response
const unauthorized = { status: 401 } as Response
let mockFetch: ReturnType<typeof vi.fn>
beforeEach(() => {
mockRemint.mockReset()
flagState.unifiedCloudAuthEnabled = true
mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('re-mints once and retries with the fresh token on a 401 (AC1)', async () => {
mockFetch.mockResolvedValueOnce(unauthorized).mockResolvedValueOnce(ok)
mockRemint.mockResolvedValue('tokenB')
const result = await fetchWithUnifiedRemint(
'https://cloud/x',
{ headers: { Authorization: 'Bearer tokenA', 'Comfy-User': 'u1' } },
true
)
expect(result).toBe(ok)
expect(mockFetch).toHaveBeenCalledTimes(2)
expect(mockRemint).toHaveBeenCalledTimes(1)
const retryHeaders = new Headers(mockFetch.mock.calls[1][1].headers)
expect(retryHeaders.get('Authorization')).toBe('Bearer tokenB')
expect(retryHeaders.get('Comfy-User')).toBe('u1')
})
it('surfaces a persistent 401 after exactly one retry (AC2)', async () => {
const secondUnauthorized = { status: 401 } as Response
mockFetch
.mockResolvedValueOnce(unauthorized)
.mockResolvedValueOnce(secondUnauthorized)
mockRemint.mockResolvedValue('tokenB')
const result = await fetchWithUnifiedRemint(
'https://cloud/x',
{ headers: { Authorization: 'Bearer tokenA' } },
true
)
expect(result).toBe(secondUnauthorized)
expect(mockFetch).toHaveBeenCalledTimes(2)
expect(mockRemint).toHaveBeenCalledTimes(1)
})
it('does not re-mint or retry when the caller gate is false (AC3)', async () => {
mockFetch.mockResolvedValueOnce(unauthorized)
const result = await fetchWithUnifiedRemint(
'https://cloud/x',
{ headers: { Authorization: 'Bearer tokenA' } },
false
)
expect(result).toBe(unauthorized)
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(mockRemint).not.toHaveBeenCalled()
})
it('does not retry a non-401 response', async () => {
const serverError = { status: 500 } as Response
mockFetch.mockResolvedValueOnce(serverError)
const result = await fetchWithUnifiedRemint(
'https://cloud/x',
{ headers: { Authorization: 'Bearer t' } },
true
)
expect(result).toBe(serverError)
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(mockRemint).not.toHaveBeenCalled()
})
it('surfaces the original 401 when the re-mint yields no token', async () => {
mockFetch.mockResolvedValueOnce(unauthorized)
mockRemint.mockResolvedValue(null)
const result = await fetchWithUnifiedRemint(
'https://cloud/x',
{ headers: { Authorization: 'Bearer t' } },
true
)
expect(result).toBe(unauthorized)
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(mockRemint).toHaveBeenCalledTimes(1)
})
it('surfaces the original 401 when the re-mint throws a permanent auth error', async () => {
mockFetch.mockResolvedValueOnce(unauthorized)
mockRemint.mockRejectedValue(new Error('INVALID_FIREBASE_TOKEN'))
const result = await fetchWithUnifiedRemint(
'https://cloud/x',
{ headers: { Authorization: 'Bearer t' } },
true
)
expect(result).toBe(unauthorized)
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(mockRemint).toHaveBeenCalledTimes(1)
})
it('surfaces the original 401 without re-minting when the body is a non-replayable stream', async () => {
mockFetch.mockResolvedValueOnce(unauthorized)
mockRemint.mockResolvedValue('tokenB')
const result = await fetchWithUnifiedRemint(
'https://cloud/x',
{
method: 'POST',
body: new ReadableStream<Uint8Array>(),
headers: { Authorization: 'Bearer tokenA' }
},
true
)
expect(result).toBe(unauthorized)
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(mockRemint).not.toHaveBeenCalled()
})
it.for([
{
shape: 'object',
headers: { Authorization: 'Bearer tokenA', 'Comfy-User': 'u1' }
},
{
shape: 'array of tuples',
headers: [
['Authorization', 'Bearer tokenA'],
['Comfy-User', 'u1']
]
},
{
shape: 'Headers',
headers: new Headers({
Authorization: 'Bearer tokenA',
'Comfy-User': 'u1'
})
}
] as { shape: string; headers: HeadersInit }[])(
'preserves method/body and replaces Authorization on a POST retry ($shape headers)',
async ({ headers }) => {
mockFetch.mockResolvedValueOnce(unauthorized).mockResolvedValueOnce(ok)
mockRemint.mockResolvedValue('tokenB')
const body = JSON.stringify({ amount: 5 })
await fetchWithUnifiedRemint(
'https://cloud/x',
{ method: 'POST', body, headers },
true
)
const retryInit = mockFetch.mock.calls[1][1]
expect(retryInit.method).toBe('POST')
expect(retryInit.body).toBe(body)
const retryHeaders = new Headers(retryInit.headers)
expect(retryHeaders.get('Authorization')).toBe('Bearer tokenB')
expect(retryHeaders.get('Comfy-User')).toBe('u1')
}
)
})
describe('attachUnifiedRemintInterceptor', () => {
beforeEach(() => {
mockRemint.mockReset()
flagState.unifiedCloudAuthEnabled = true
})
// A custom axios adapter is responsible for its own status handling (axios
// only applies validateStatus inside its built-in adapters), so reject
// non-2xx with a real AxiosError to mirror a live response.
function makeAdapter(statuses: number[]): ReturnType<typeof vi.fn> {
let call = 0
return vi.fn<AxiosAdapter>(async (config) => {
const status = statuses[Math.min(call, statuses.length - 1)]
call++
const response = {
data: status === 200 ? { ok: true } : { message: 'unauthorized' },
status,
statusText: String(status),
headers: {},
config
}
if (status >= 200 && status < 300) {
return response
}
throw new AxiosError(
`Request failed with status code ${status}`,
AxiosError.ERR_BAD_REQUEST,
config,
null,
response
)
})
}
function makeClient(statuses: number[]) {
const adapter = makeAdapter(statuses)
const client = axios.create({ adapter: adapter as unknown as AxiosAdapter })
attachUnifiedRemintInterceptor(client)
return { client, adapter }
}
it('re-mints once and retries the request with the fresh token (AC1)', async () => {
const { client, adapter } = makeClient([401, 200])
mockRemint.mockResolvedValue('tokenB')
const res = await client.get('https://cloud/x', {
headers: { Authorization: 'Bearer tokenA' }
})
expect(res.status).toBe(200)
expect(adapter).toHaveBeenCalledTimes(2)
expect(mockRemint).toHaveBeenCalledTimes(1)
expect(String(adapter.mock.calls[1][0].headers.Authorization)).toBe(
'Bearer tokenB'
)
})
it('retries once then surfaces a persistent 401 (AC2)', async () => {
const { client, adapter } = makeClient([401, 401])
mockRemint.mockResolvedValue('tokenB')
await expect(
client.get('https://cloud/x', {
headers: { Authorization: 'Bearer tokenA' }
})
).rejects.toMatchObject({ response: { status: 401 } })
expect(adapter).toHaveBeenCalledTimes(2)
expect(mockRemint).toHaveBeenCalledTimes(1)
})
it('does not re-mint when the flag is OFF (AC3)', async () => {
flagState.unifiedCloudAuthEnabled = false
const { client, adapter } = makeClient([401])
await expect(
client.get('https://cloud/x', {
headers: { Authorization: 'Bearer tokenA' }
})
).rejects.toMatchObject({ response: { status: 401 } })
expect(adapter).toHaveBeenCalledTimes(1)
expect(mockRemint).not.toHaveBeenCalled()
})
it('does not re-mint a request flagged __skipUnifiedRemint (acceptInvite)', async () => {
const { client, adapter } = makeClient([401])
mockRemint.mockResolvedValue('tokenB')
await expect(
client.post('https://cloud/invites/x/accept', null, {
headers: { Authorization: 'Bearer firebase' },
__skipUnifiedRemint: true
})
).rejects.toMatchObject({ response: { status: 401 } })
expect(adapter).toHaveBeenCalledTimes(1)
expect(mockRemint).not.toHaveBeenCalled()
})
it('passes a non-401 error through without re-minting', async () => {
const { client } = makeClient([500])
await expect(
client.get('https://cloud/x', { headers: { Authorization: 'Bearer t' } })
).rejects.toMatchObject({ response: { status: 500 } })
expect(mockRemint).not.toHaveBeenCalled()
})
it('preserves the POST body and method on a retry, with the fresh token', async () => {
const { client, adapter } = makeClient([401, 200])
mockRemint.mockResolvedValue('tokenB')
const res = await client.post(
'https://cloud/topup',
{ amount: 5 },
{ headers: { Authorization: 'Bearer tokenA' } }
)
expect(res.status).toBe(200)
expect(adapter).toHaveBeenCalledTimes(2)
const firstConfig = adapter.mock.calls[0][0]
const retryConfig = adapter.mock.calls[1][0]
expect(retryConfig.method).toBe('post')
expect(retryConfig.data).toBe(firstConfig.data)
expect(String(retryConfig.headers.Authorization)).toBe('Bearer tokenB')
})
it('latches per request — a second request still retries once (no shared latch)', async () => {
// Per-URL: first call 401, second (the retry) 200. The latch lives on each
// request's config, so a second request must retry independently.
const callsByUrl = new Map<string, number>()
const adapter = vi.fn<AxiosAdapter>(async (config) => {
const url = config.url ?? ''
const nth = (callsByUrl.get(url) ?? 0) + 1
callsByUrl.set(url, nth)
const okStatus = nth >= 2
const response = {
data: okStatus ? { ok: true } : { message: 'unauthorized' },
status: okStatus ? 200 : 401,
statusText: okStatus ? '200' : '401',
headers: {},
config
}
if (okStatus) return response
throw new AxiosError(
'Request failed with status code 401',
AxiosError.ERR_BAD_REQUEST,
config,
null,
response
)
})
const client = axios.create({ adapter: adapter as unknown as AxiosAdapter })
attachUnifiedRemintInterceptor(client)
mockRemint.mockResolvedValue('tokenB')
const a = await client.get('https://cloud/a', {
headers: { Authorization: 'Bearer tokenA' }
})
const b = await client.get('https://cloud/b', {
headers: { Authorization: 'Bearer tokenA' }
})
expect(a.status).toBe(200)
expect(b.status).toBe(200)
// Each request: initial 401 + one retry = 4 adapter calls, one re-mint each.
expect(adapter).toHaveBeenCalledTimes(4)
expect(mockRemint).toHaveBeenCalledTimes(2)
})
})

View File

@@ -1,131 +0,0 @@
import type {
AxiosError,
AxiosInstance,
InternalAxiosRequestConfig
} from 'axios'
import axios, { AxiosHeaders } from 'axios'
import { isCloud } from '@/platform/distribution/types'
let cachedUnifiedFlags:
| { readonly unifiedCloudAuthEnabled: boolean }
| undefined
/**
* Single gate for the reactive guard: a cloud build with `unified_cloud_auth`
* ON. Memoizes the feature-flag accessor so the hot `fetchApi` path does not
* build a fresh reactive proxy per request (the cached getter still reflects
* live flag changes), and is reused at every cloud request seam so the gate
* cannot be forgotten on a new call site.
*/
export async function shouldRemintCloudRequest(): Promise<boolean> {
if (!isCloud) return false
if (!cachedUnifiedFlags) {
const { useFeatureFlags } = await import('@/composables/useFeatureFlags')
cachedUnifiedFlags = useFeatureFlags().flags
}
return cachedUnifiedFlags.unifiedCloudAuthEnabled
}
/**
* Re-mints the unified Cloud JWT once from the current Firebase identity and
* returns the fresh token, or `null` when there is nothing to retry with: no
* active unified session, or the re-mint failed. A permanent auth failure is
* surfaced + torn down inside `remintUnifiedOnce` (error toast + session clear,
* matching the proactive refresh path); the `catch` here only guards an
* unexpected throw (e.g. a chunk-load failure or no active Pinia), which it
* logs. Either way `null` makes the caller surface its original 401 unchanged.
*/
async function tryRemintToken(): Promise<string | null> {
try {
const { useWorkspaceAuthStore } =
await import('@/platform/workspace/stores/workspaceAuthStore')
return await useWorkspaceAuthStore().remintUnifiedOnce()
} catch (err) {
console.warn('Unified re-mint primitive threw unexpectedly:', err)
return null
}
}
/**
* Issues a `fetch` and, on a `401`, re-mints the unified Cloud JWT once and
* retries the request exactly once with the fresh token. A persistent `401`
* (or a `null` re-mint) surfaces the original Response unchanged — no retry
* loop. Requires a replayable body: a one-shot `ReadableStream` body cannot be
* replayed, so such a request surfaces its original `401` without a retry (no
* current cloud caller sends one).
*
* `shouldRetryOn401` is the caller's gate (see {@link shouldRemintCloudRequest}):
* flag-OFF traffic returns after a single `fetch` and never enters the re-mint
* path, so the legacy cascade stays untouched for instant rollback.
*/
export async function fetchWithUnifiedRemint(
input: RequestInfo | URL,
init: RequestInit,
shouldRetryOn401: boolean
): Promise<Response> {
const response = await fetch(input, init)
if (!shouldRetryOn401 || response.status !== 401) {
return response
}
if (init.body instanceof ReadableStream) {
console.warn(
'fetchWithUnifiedRemint: a ReadableStream body is not replayable; surfacing the original 401'
)
return response
}
const token = await tryRemintToken()
if (!token) {
return response
}
const headers = new Headers(init.headers)
headers.set('Authorization', `Bearer ${token}`)
return fetch(input, { ...init, headers })
}
function isRetriableUnauthorized(
error: unknown
): error is AxiosError & { config: InternalAxiosRequestConfig } {
if (!axios.isAxiosError(error)) return false
const config = error.config
if (!config || config.__unifiedRetried || config.__skipUnifiedRemint) {
return false
}
return error.response?.status === 401
}
/**
* Installs a response interceptor that gives a cloud axios client the same
* reactive 401 guard as {@link fetchWithUnifiedRemint}: a single re-mint + a
* single retry on `401`, surfacing a persistent `401` unchanged. A strict
* no-op while `unified_cloud_auth` is OFF — the original error rejects exactly
* as it does today.
*/
export function attachUnifiedRemintInterceptor(client: AxiosInstance): void {
client.interceptors.response.use(
(response) => response,
async (error: unknown) => {
if (
!isRetriableUnauthorized(error) ||
!(await shouldRemintCloudRequest())
) {
throw error
}
const token = await tryRemintToken()
if (!token) {
throw error
}
// Clone (don't mutate) the caller's config so the re-minted Bearer never
// leaks into a caller-retained reference, matching fetchWithUnifiedRemint.
const { config } = error
const headers = new AxiosHeaders(config.headers)
headers.set('Authorization', `Bearer ${token}`)
return client.request({ ...config, headers, __unifiedRetried: true })
}
)
}

View File

@@ -59,15 +59,6 @@ vi.mock('@/platform/cloud/subscription/utils/subscriptionCheckoutUtil', () => ({
mockPerformSubscriptionCheckout(...args)
}))
const mockPerformTeamSubscriptionCheckout = vi.fn()
vi.mock(
'@/platform/cloud/subscription/utils/teamSubscriptionCheckoutUtil',
() => ({
performTeamSubscriptionCheckout: (...args: unknown[]) =>
mockPerformTeamSubscriptionCheckout(...args)
})
)
const createI18nInstance = () =>
createI18n({
legacy: false,
@@ -82,7 +73,6 @@ const createI18nInstance = () =>
},
subscription: {
subscribeTo: 'Subscribe to {plan}',
teamPlan: { name: 'Team Plan' },
tiers: {
standard: { name: 'Standard' },
creator: { name: 'Creator' },
@@ -172,24 +162,4 @@ describe('CloudSubscriptionRedirectView', () => {
false
)
})
test('checks out the team plan via the workspace path with the chosen stop and cycle', async () => {
await mountView({ tier: 'team', stop: 'team_700', cycle: 'yearly' })
expect(mockRouterPush).not.toHaveBeenCalledWith('/')
expect(screen.getByText('Subscribe to Team Plan')).toBeInTheDocument()
expect(mockPerformTeamSubscriptionCheckout).toHaveBeenCalledWith(
'team_700',
'yearly'
)
// Team never goes through the personal checkout path
expect(mockPerformSubscriptionCheckout).not.toHaveBeenCalled()
})
test('redirects to home for a team link with no stop', async () => {
await mountView({ tier: 'team', cycle: 'yearly' })
expect(mockRouterPush).toHaveBeenCalledWith('/')
expect(mockPerformTeamSubscriptionCheckout).not.toHaveBeenCalled()
})
})

View File

@@ -10,19 +10,9 @@ import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import { performSubscriptionCheckout } from '@/platform/cloud/subscription/utils/subscriptionCheckoutUtil'
import { performTeamSubscriptionCheckout } from '@/platform/cloud/subscription/utils/teamSubscriptionCheckoutUtil'
import type { BillingCycle } from '../subscription/utils/subscriptionTierRank'
function isBillingCycle(value: string): value is BillingCycle {
return value === 'monthly' || value === 'yearly'
}
// Only paid personal tiers can be checked out via this redirect.
function isCheckoutTierKey(value: string): value is TierKey {
return ['standard', 'creator', 'pro', 'founder'].includes(value)
}
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
@@ -45,12 +35,6 @@ const tierDisplayName = computed(() => {
return names[selectedTierKey.value]
})
const isTeamCheckout = ref(false)
const planLabel = computed(() =>
isTeamCheckout.value ? t('subscription.teamPlan.name') : tierDisplayName.value
)
const runRedirect = wrapWithErrorHandlingAsync(async () => {
const rawType = route.query.tier
const rawCycle = route.query.cycle
@@ -74,36 +58,21 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
return
}
const billingCycle: BillingCycle = isBillingCycle(cycleParam)
? cycleParam
: 'monthly'
// Team is a per-credit plan picked on a slider, so it carries a `stop` (the
// chosen credit commitment) instead of a tier and checks out through the
// workspace billing endpoint rather than the personal one.
if (tierKeyParam === 'team') {
const rawStop = route.query.stop
const stopId =
typeof rawStop === 'string'
? rawStop
: Array.isArray(rawStop)
? rawStop[0]
: null
if (!stopId) {
await router.push('/')
return
}
isTeamCheckout.value = true
await performTeamSubscriptionCheckout(stopId, billingCycle)
return
}
if (!isCheckoutTierKey(tierKeyParam)) {
// Only paid tiers can be checked out via redirect
const validTierKeys: TierKey[] = ['standard', 'creator', 'pro', 'founder']
if (!(validTierKeys as string[]).includes(tierKeyParam)) {
await router.push('/')
return
}
selectedTierKey.value = tierKeyParam
const tierKey = tierKeyParam as TierKey
selectedTierKey.value = tierKey
const validCycles: BillingCycle[] = ['monthly', 'yearly']
if (!cycleParam || !(validCycles as string[]).includes(cycleParam)) {
cycleParam = 'monthly'
}
if (!isInitialized.value) {
await initialize()
@@ -112,7 +81,11 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
if (isActiveSubscription.value) {
await accessBillingPortal(undefined, false)
} else {
await performSubscriptionCheckout(tierKeyParam, billingCycle, false)
await performSubscriptionCheckout(
tierKey,
cycleParam as BillingCycle,
false
)
}
}, reportError)
@@ -132,18 +105,18 @@ onMounted(() => {
class="size-16"
/>
<p
v-if="planLabel"
v-if="selectedTierKey"
class="font-inter text-base/normal font-normal text-base-foreground"
>
{{
t('subscription.subscribeTo', {
plan: planLabel
plan: tierDisplayName
})
}}
</p>
<ProgressSpinner v-if="planLabel" class="size-8" stroke-width="4" />
<ProgressSpinner v-if="selectedTierKey" class="size-8" stroke-width="4" />
<Button
v-if="planLabel"
v-if="selectedTierKey"
as="a"
href="/"
link

View File

@@ -1,373 +0,0 @@
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed } from 'vue'
import { createI18n } from 'vue-i18n'
import type { BalanceInfo, SubscriptionInfo } from '@/composables/billing/types'
import CreditsTile from '@/platform/cloud/subscription/components/CreditsTile.vue'
import type { CurrentTeamCreditStop } from '@/platform/workspace/api/workspaceApi'
type Balance = Pick<
BalanceInfo,
'amountMicros' | 'cloudCreditBalanceMicros' | 'prepaidBalanceMicros'
>
type Subscription = Pick<SubscriptionInfo, 'duration' | 'renewalDate'> & {
tier: SubscriptionInfo['tier'] | 'TEAM'
}
type TeamStop = CurrentTeamCreditStop
const state = vi.hoisted(() => ({
balance: null as Balance | null,
subscription: null as Subscription | null,
isActiveSubscription: false,
isFreeTier: false,
currentTeamCreditStop: null as TeamStop | null,
isLoading: false,
canTopUp: true,
fetchBalance: vi.fn(),
fetchStatus: vi.fn(),
showPricingTable: vi.fn(),
showTopUpCreditsDialog: vi.fn(),
trackAddApiCreditButtonClicked: vi.fn(),
toastErrorHandler: vi.fn()
}))
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
wrapWithErrorHandlingAsync:
<TArgs extends unknown[], TReturn>(
action: (...args: TArgs) => Promise<TReturn> | TReturn
) =>
async (...args: TArgs): Promise<TReturn | undefined> => {
try {
return await action(...args)
} catch (e) {
state.toastErrorHandler(e)
}
}
})
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
balance: computed(() => state.balance),
subscription: computed(() => state.subscription),
isActiveSubscription: computed(() => state.isActiveSubscription),
isFreeTier: computed(() => state.isFreeTier),
currentTeamCreditStop: computed(() => state.currentTeamCreditStop),
isLoading: computed(() => state.isLoading),
fetchBalance: state.fetchBalance,
fetchStatus: state.fetchStatus
})
}))
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
permissions: computed(() => ({ canTopUp: state.canTopUp }))
})
}))
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
() => ({
useSubscriptionDialog: () => ({ showPricingTable: state.showPricingTable })
})
)
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showTopUpCreditsDialog: state.showTopUpCreditsDialog
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackAddApiCreditButtonClicked: state.trackAddApiCreditButtonClicked
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
subscription: {
totalCredits: 'Total credits',
remaining: 'remaining',
refreshCredits: 'Refresh credits',
monthly: 'Monthly',
refillsDate: 'Refills {date}',
refillsNextCycle: 'Refills next cycle',
creditsUsed: '{used} used',
creditsLeftOfTotal: '{remaining} left of {total}',
monthlyUsageProgress: '{used} of {total} monthly credits used',
additionalCreditsInfo: 'About additional credits',
additionalCreditsTooltip: 'Credits you add on top of your plan.',
additionalCredits: 'Additional credits',
additionalCreditsInUse: 'In use',
usedAfterMonthly: 'Used after monthly runs out',
monthlyCreditsUsedUpTitle:
'Monthly credits are used up. Refills {date}',
monthlyCreditsUsedUpTitleNoDate: 'Monthly credits are used up',
monthlyCreditsUsedUpDescription:
"You're now spending additional credits.",
outOfCreditsTitle: "You're out of credits. Credits refill {date}",
outOfCreditsTitleNoDate: "You're out of credits",
outOfCreditsDescription: 'Add more credits to continue generating.',
addCredits: 'Add credits',
upgradeToAddCredits: 'Upgrade to add credits'
}
}
}
})
function renderTile(props: Record<string, unknown> = {}) {
return render(CreditsTile, {
props,
global: {
plugins: [i18n],
directives: { tooltip: () => {} },
stubs: {
Button: {
template:
'<button v-bind="$attrs" :data-variant="variant" :disabled="loading" @click="$emit(\'click\')"><slot/></button>',
props: ['variant', 'size', 'loading'],
emits: ['click']
},
Skeleton: { template: '<div role="status" aria-label="Loading"></div>' }
}
}
})
}
function activeProSubscription() {
state.isActiveSubscription = true
state.subscription = {
tier: 'PRO',
duration: 'MONTHLY',
renewalDate: '2026-02-20T12:00:00Z'
}
// amountMicros are cents; centsToCredits multiplies by 2.11.
state.balance = {
amountMicros: 500, // -> 1,055 total
cloudCreditBalanceMicros: 200, // -> 422 monthly remaining
prepaidBalanceMicros: 300 // -> 633 additional
}
}
describe('CreditsTile', () => {
beforeEach(() => {
state.balance = null
state.subscription = null
state.isActiveSubscription = false
state.isFreeTier = false
state.currentTeamCreditStop = null
state.isLoading = false
state.canTopUp = true
vi.clearAllMocks()
})
it('renders the total balance (cents converted to credits) with the remaining suffix', () => {
activeProSubscription()
const { container } = renderTile()
expect(container.textContent).toContain('1,055')
expect(container.textContent).toContain('remaining')
})
it('renders the monthly usage bar and additional breakdown', () => {
activeProSubscription()
const { container } = renderTile()
// PRO monthly allowance = 21,100; remaining 422 -> used 20,678.
expect(container.textContent).toContain('Monthly')
expect(container.textContent).toMatch(/Refills Feb/)
expect(container.textContent).toContain('20,678 used')
expect(container.textContent).toContain('422 left of 21,100')
expect(container.textContent).toContain('Additional credits')
expect(container.textContent).toContain('633')
expect(container.textContent).toContain('Used after monthly runs out')
})
it('renders a compact monthly summary for narrow containers', () => {
activeProSubscription()
const { container } = renderTile()
expect(container.textContent).toContain('422 left of 21K')
})
it('uses the team credit stop monthly grant for the monthly total', () => {
state.isActiveSubscription = true
state.subscription = {
tier: 'TEAM',
duration: 'ANNUAL',
renewalDate: '2026-02-20T12:00:00Z'
}
state.currentTeamCreditStop = {
id: 'team_2500',
credits_monthly: 527500,
stop_usd: 2500
}
state.balance = { amountMicros: 0, cloudCreditBalanceMicros: 200 }
const { container } = renderTile()
// Monthly total is the stop's raw monthly grant, not the tier fallback,
// and is not multiplied by 12 for annual billing.
expect(container.textContent).toContain('422 left of 527,500')
})
it('uses the per-month nominal grant for an annual personal tier', () => {
state.isActiveSubscription = true
state.subscription = {
tier: 'PRO',
duration: 'ANNUAL',
renewalDate: '2026-02-20T12:00:00Z'
}
state.balance = { amountMicros: 0, cloudCreditBalanceMicros: 200 }
const { container } = renderTile()
// Annual billing still grants the monthly nominal (21,100), not 12x.
expect(container.textContent).toContain('422 left of 21,100')
expect(container.textContent).not.toContain('253,200')
})
it('falls back to a dateless refills label when renewal date is missing', () => {
activeProSubscription()
state.subscription = { tier: 'PRO', duration: 'MONTHLY', renewalDate: null }
const { container } = renderTile()
expect(container.textContent).toContain('Refills next cycle')
expect(container.textContent).not.toContain('Refills Feb')
})
it('uses a dateless out-of-credits notice when renewal date is invalid', () => {
activeProSubscription()
state.subscription = {
tier: 'PRO',
duration: 'MONTHLY',
renewalDate: 'not-a-date'
}
state.balance = {
amountMicros: 0,
cloudCreditBalanceMicros: 0,
prepaidBalanceMicros: 0
}
const { container } = renderTile()
expect(container.textContent).toContain("You're out of credits")
expect(container.textContent).not.toContain('Credits refill')
})
it('hides the breakdown and forces zeros in the zero state', () => {
activeProSubscription()
const { container } = renderTile({ zeroState: true })
expect(container.textContent).toContain('0')
expect(container.textContent).not.toContain('left of')
expect(container.textContent).not.toContain('Additional credits')
expect(screen.queryByText('Add credits')).toBeNull()
})
it('shows only the balance with no breakdown when there is no active subscription', () => {
state.isActiveSubscription = false
state.balance = { amountMicros: 500 }
const { container } = renderTile()
expect(container.textContent).toContain('1,055')
expect(container.textContent).not.toContain('left of')
expect(container.textContent).not.toContain('Additional credits')
expect(screen.queryByText('Add credits')).toBeNull()
})
it('shows no depletion notice or in-use badge while monthly credits remain', () => {
activeProSubscription()
const { container } = renderTile()
expect(container.textContent).not.toContain('Monthly credits are used up')
expect(container.textContent).not.toContain("You're out of credits")
expect(screen.queryByText('In use')).toBeNull()
})
it('flags spending of additional credits once the monthly allowance is depleted', () => {
activeProSubscription()
state.balance = {
amountMicros: 300,
cloudCreditBalanceMicros: 0,
prepaidBalanceMicros: 300
}
const { container } = renderTile()
expect(container.textContent).toContain(
'Monthly credits are used up. Refills Feb 20'
)
expect(container.textContent).toContain(
"You're now spending additional credits."
)
expect(screen.getByText('In use')).toBeTruthy()
expect(screen.getByText('Add credits').dataset.variant).toBe('secondary')
})
it('emphasizes add-credits when fully out of credits', () => {
activeProSubscription()
state.balance = {
amountMicros: 0,
cloudCreditBalanceMicros: 0,
prepaidBalanceMicros: 0
}
const { container } = renderTile()
expect(container.textContent).toContain(
"You're out of credits. Credits refill Feb 20"
)
expect(container.textContent).toContain(
'Add more credits to continue generating.'
)
expect(screen.queryByText('In use')).toBeNull()
expect(screen.getByText('Add credits').dataset.variant).toBe('inverted')
})
it('suppresses the depletion notice until the balance has loaded', () => {
activeProSubscription()
state.balance = null
state.isLoading = true
const { container } = renderTile()
expect(container.textContent).not.toContain('Monthly credits are used up')
expect(container.textContent).not.toContain("You're out of credits")
})
it('routes add-credits through telemetry + the top-up dialog', async () => {
activeProSubscription()
renderTile()
await userEvent.click(screen.getByText('Add credits'))
expect(state.trackAddApiCreditButtonClicked).toHaveBeenCalledOnce()
expect(state.showTopUpCreditsDialog).toHaveBeenCalledOnce()
})
it('offers the upgrade path instead of add-credits on the free tier', async () => {
activeProSubscription()
state.isFreeTier = true
renderTile()
expect(screen.queryByText('Add credits')).toBeNull()
await userEvent.click(screen.getByText('Upgrade to add credits'))
expect(state.showPricingTable).toHaveBeenCalledOnce()
})
it('hides the action button when the user lacks the top-up permission', () => {
activeProSubscription()
state.canTopUp = false
renderTile()
expect(screen.queryByText('Add credits')).toBeNull()
expect(screen.queryByText('Upgrade to add credits')).toBeNull()
})
it('refreshes balance and status from the facade on mount and on demand', async () => {
activeProSubscription()
renderTile()
expect(state.fetchBalance).toHaveBeenCalledOnce()
expect(state.fetchStatus).toHaveBeenCalledOnce()
await userEvent.click(
screen.getByRole('button', { name: 'Refresh credits' })
)
expect(state.fetchBalance).toHaveBeenCalledTimes(2)
expect(state.fetchStatus).toHaveBeenCalledTimes(2)
})
it('surfaces a failure toast when a refresh rejects', async () => {
activeProSubscription()
const failure = new Error('network down')
state.fetchBalance.mockRejectedValueOnce(failure)
renderTile()
await waitFor(() =>
expect(state.toastErrorHandler).toHaveBeenCalledWith(failure)
)
})
})

View File

@@ -1,371 +0,0 @@
<template>
<div
class="@container relative flex flex-col gap-6 rounded-2xl border border-interface-stroke bg-modal-panel-background px-6 py-5"
>
<Button
variant="muted-textonly"
size="icon-sm"
class="absolute top-4 right-4"
:loading="isLoadingBalance"
:aria-label="$t('subscription.refreshCredits')"
@click="handleRefresh"
>
<i class="icon-[lucide--refresh-cw] size-4 text-text-secondary" />
</Button>
<div class="flex flex-col gap-1">
<div class="text-sm text-muted">
{{ $t('subscription.totalCredits') }}
</div>
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
<div v-else class="flex items-baseline gap-2">
<i class="icon-[lucide--component] size-4 self-center text-credit" />
<span class="text-2xl leading-none font-bold">{{ displayTotal }}</span>
<span class="text-sm text-muted @max-[300px]:hidden">{{
$t('subscription.remaining')
}}</span>
</div>
</div>
<template v-if="showBreakdown">
<div
v-if="emptyStateNotice"
class="flex items-start gap-2 rounded-lg bg-base-background p-3 text-sm"
>
<i
class="mt-0.5 icon-[lucide--info] size-4 shrink-0 text-base-foreground"
/>
<div class="flex flex-col gap-1">
<span class="text-base-foreground">{{ emptyStateNotice.title }}</span>
<span class="text-muted">{{ emptyStateNotice.description }}</span>
</div>
</div>
<div
v-if="showBar"
:class="cn('flex flex-col gap-2', isMonthlyDepleted && 'opacity-30')"
>
<div class="flex items-center justify-between text-sm">
<span class="text-text-primary">{{
$t('subscription.monthly')
}}</span>
<span class="text-muted">
{{ refillsLabel }}
</span>
</div>
<div
role="progressbar"
:aria-valuenow="usage.used"
:aria-valuemin="0"
:aria-valuemax="monthlyTotalCredits ?? 0"
:aria-valuetext="monthlyUsageLabel"
class="h-2 w-full overflow-hidden rounded-full bg-secondary-background-hover"
>
<div
class="h-full rounded-full bg-credit"
:style="{ width: usedBarWidth }"
/>
</div>
<div class="flex items-center justify-between gap-2 text-sm">
<Skeleton
v-if="isLoadingBalance"
class="@max-[300px]:hidden"
width="5rem"
height="1rem"
/>
<span v-else class="text-muted @max-[300px]:hidden">
{{ $t('subscription.creditsUsed', { used: usedDisplay }) }}
</span>
<Skeleton v-if="isLoadingBalance" width="9rem" height="1rem" />
<span
v-else
class="flex items-center gap-1 font-bold text-text-primary"
>
<i class="icon-[lucide--component] size-4 text-credit" />
<span class="@max-[180px]:hidden">
{{
$t('subscription.creditsLeftOfTotal', {
remaining: monthlyBonusCredits,
total: monthlyTotalDisplay
})
}}
</span>
<span class="hidden @max-[180px]:inline">
{{
$t('subscription.creditsLeftOfTotal', {
remaining: monthlyRemainingCompact,
total: monthlyTotalCompact
})
}}
</span>
</span>
</div>
</div>
<div class="h-px w-full bg-interface-stroke" />
<div class="flex flex-col gap-2">
<div
class="flex items-center justify-between gap-2 text-sm @max-[300px]:flex-col @max-[300px]:items-start"
>
<span class="flex items-center gap-1 text-text-primary">
{{ $t('subscription.additionalCredits') }}
<button
v-tooltip="{
value: $t('subscription.additionalCreditsTooltip'),
showDelay: 300
}"
type="button"
:aria-label="$t('subscription.additionalCreditsInfo')"
class="flex items-center text-muted"
>
<i class="icon-[lucide--info] size-4" />
</button>
<span
v-if="isSpendingAdditional"
class="flex h-3.5 items-center rounded-full bg-base-foreground px-1 text-2xs/none font-semibold text-base-background uppercase"
>
{{ $t('subscription.additionalCreditsInUse') }}
</span>
</span>
<Skeleton v-if="isLoadingBalance" width="3rem" height="1rem" />
<span
v-else
class="flex items-center gap-1 font-bold text-text-primary"
>
<i class="icon-[lucide--component] size-4 text-credit" />
{{ displayPrepaid }}
</span>
</div>
<span class="text-sm text-muted @max-[300px]:hidden">
{{ $t('subscription.usedAfterMonthly') }}
</span>
</div>
</template>
<div v-if="showActionButton" class="flex flex-col gap-3">
<Button
v-if="isFreeTier"
variant="gradient"
size="lg"
class="w-full font-normal"
@click="handleUpgradeToAddCredits"
>
{{ $t('subscription.upgradeToAddCredits') }}
</Button>
<Button
v-else
:variant="isOutOfCredits ? 'inverted' : 'secondary'"
size="lg"
:class="
cn(
'w-full font-normal',
!isOutOfCredits &&
'bg-interface-menu-component-surface-selected text-text-primary'
)
"
@click="handleAddCredits"
>
{{ $t('subscription.addCredits') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { useEventListener } from '@vueuse/core'
import Skeleton from 'primevue/skeleton'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCredits } from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import {
DEFAULT_TIER_KEY,
TIER_TO_KEY,
getTierCredits
} from '@/platform/cloud/subscription/constants/tierPricing'
import { computeMonthlyUsage } from '@/platform/cloud/subscription/utils/creditsProgress'
import { useTelemetry } from '@/platform/telemetry'
import { consumePendingTopup } from '@/platform/telemetry/topupTracker'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useDialogService } from '@/services/dialogService'
const { zeroState = false } = defineProps<{
/** Forces the zero-credit display (e.g. unsubscribed / member view). */
zeroState?: boolean
}>()
const { locale, t } = useI18n()
const {
subscription,
balance,
isActiveSubscription,
isFreeTier,
currentTeamCreditStop,
fetchBalance,
fetchStatus
} = useBillingContext()
const {
monthlyBonusCredits,
prepaidCredits,
totalCredits,
monthlyBonusCreditsValue,
prepaidCreditsValue,
isLoadingBalance
} = useSubscriptionCredits()
const { permissions } = useWorkspaceUI()
const { showPricingTable } = useSubscriptionDialog()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const dialogService = useDialogService()
const telemetry = useTelemetry()
const tierKey = computed(() => {
const tier = subscription.value?.tier
if (!tier) return DEFAULT_TIER_KEY
return TIER_TO_KEY[tier] ?? DEFAULT_TIER_KEY
})
const monthlyTotalCredits = computed<number | null>(() => {
const teamStop = currentTeamCreditStop.value
if (teamStop) return teamStop.credits_monthly
return getTierCredits(tierKey.value)
})
const usage = computed(() =>
computeMonthlyUsage(
monthlyBonusCreditsValue.value,
monthlyTotalCredits.value ?? 0
)
)
const refillsDateShort = computed(() => {
const raw = subscription.value?.renewalDate
if (!raw) return ''
const date = new Date(raw)
return Number.isNaN(date.getTime())
? ''
: date.toLocaleDateString(locale.value, { month: 'short', day: 'numeric' })
})
const hasRefillsDate = computed(() => refillsDateShort.value !== '')
const refillsLabel = computed(() =>
hasRefillsDate.value
? t('subscription.refillsDate', { date: refillsDateShort.value })
: t('subscription.refillsNextCycle')
)
const formatCreditCount = (value: number) =>
formatCredits({
value,
locale: locale.value,
numberOptions: { maximumFractionDigits: 0 }
})
const monthlyTotalDisplay = computed(() => {
const total = monthlyTotalCredits.value
return total === null ? '—' : formatCreditCount(total)
})
const usedDisplay = computed(() => formatCreditCount(usage.value.used))
const compactNumber = computed(
() => new Intl.NumberFormat(locale.value, { notation: 'compact' })
)
const monthlyRemainingCompact = computed(() =>
compactNumber.value.format(monthlyBonusCreditsValue.value)
)
const monthlyTotalCompact = computed(() => {
const total = monthlyTotalCredits.value
return total === null ? '—' : compactNumber.value.format(total)
})
const displayTotal = computed(() => (zeroState ? '0' : totalCredits.value))
const displayPrepaid = computed(() => (zeroState ? '0' : prepaidCredits.value))
const usedBarWidth = computed(
() => `${(usage.value.usedFraction * 100).toFixed(2)}%`
)
const monthlyUsageLabel = computed(() =>
t('subscription.monthlyUsageProgress', {
used: usedDisplay.value,
total: monthlyTotalDisplay.value
})
)
const showBreakdown = computed(() => isActiveSubscription.value && !zeroState)
const showBar = computed(
() =>
showBreakdown.value &&
monthlyTotalCredits.value !== null &&
monthlyTotalCredits.value > 0
)
const showActionButton = computed(
() => isActiveSubscription.value && !zeroState && permissions.value.canTopUp
)
const isMonthlyDepleted = computed(
() =>
showBar.value &&
!isLoadingBalance.value &&
balance.value != null &&
monthlyBonusCreditsValue.value <= 0
)
const isOutOfCredits = computed(
() => isMonthlyDepleted.value && prepaidCreditsValue.value <= 0
)
const isSpendingAdditional = computed(
() => isMonthlyDepleted.value && prepaidCreditsValue.value > 0
)
const emptyStateNotice = computed(() => {
if (isOutOfCredits.value) {
return {
title: hasRefillsDate.value
? t('subscription.outOfCreditsTitle', { date: refillsDateShort.value })
: t('subscription.outOfCreditsTitleNoDate'),
description: t('subscription.outOfCreditsDescription')
}
}
if (isMonthlyDepleted.value) {
return {
title: hasRefillsDate.value
? t('subscription.monthlyCreditsUsedUpTitle', {
date: refillsDateShort.value
})
: t('subscription.monthlyCreditsUsedUpTitleNoDate'),
description: t('subscription.monthlyCreditsUsedUpDescription')
}
}
return null
})
const handleRefresh = wrapWithErrorHandlingAsync(async () => {
await Promise.all([fetchBalance(), fetchStatus()])
})
function handleAddCredits() {
telemetry?.trackAddApiCreditButtonClicked()
void dialogService.showTopUpCreditsDialog()
}
function handleUpgradeToAddCredits() {
showPricingTable()
}
async function handleWindowFocus() {
if (consumePendingTopup()) {
await handleRefresh()
}
}
useEventListener(window, 'focus', () => void handleWindowFocus())
onMounted(handleRefresh)
</script>

View File

@@ -132,19 +132,6 @@ const i18n = createI18n({
partnerNodesCredits: 'Partner nodes pricing',
renewsDate: 'Renews {date}',
expiresDate: 'Expires {date}',
monthlyCreditsLabel: 'monthly credits',
maxDurationLabel: 'max run duration',
maxDuration: {
free: '5 min',
standard: '30 min',
creator: '30 min',
pro: '1 hr',
founder: '30 min'
},
gpuLabel: 'RTX 6000 Pro (96GB VRAM)',
addCreditsLabel: 'Add more credits whenever',
customLoRAsLabel: 'Import your own LoRAs',
membersLabel: '{count} members',
tiers: {
founder: {
name: "Founder's Edition",
@@ -213,7 +200,6 @@ function createComponent(overrides = {}) {
CloudBadge: true,
SubscribeButton: true,
SubscriptionBenefits: true,
CreditsTile: true,
Button: {
template:
'<button v-bind="$attrs" @click="$emit(\'click\')" :disabled="loading" :data-testid="label" :data-icon="icon"><slot/></button>',
@@ -254,6 +240,7 @@ describe('SubscriptionPanel', () => {
mockIsActiveSubscription.value = true
const { container } = createComponent()
expect(container.textContent).toContain('Manage Subscription')
expect(container.textContent).toContain('Add Credits')
})
it('shows correct UI for inactive subscription', () => {
@@ -262,6 +249,7 @@ describe('SubscriptionPanel', () => {
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('subscribe-button-stub')).not.toBeNull()
expect(container.textContent).not.toContain('Manage Subscription')
expect(container.textContent).not.toContain('Add Credits')
})
it('shows renewal date for active non-cancelled subscription', () => {
@@ -278,19 +266,58 @@ describe('SubscriptionPanel', () => {
expect(container.textContent).toContain('Expires 2024-12-31')
})
it('displays FOUNDERS_EDITION tier without the custom-LoRA perk', () => {
it('displays FOUNDERS_EDITION tier correctly', () => {
mockSubscriptionTier.value = 'FOUNDERS_EDITION'
const { container } = createComponent()
expect(container.textContent).toContain("Founder's Edition")
expect(container.textContent).toContain('RTX 6000 Pro (96GB VRAM)')
expect(container.textContent).not.toContain('Import your own LoRAs')
expect(container.textContent).toContain('5,460')
})
it('displays CREATOR tier with the custom-LoRA perk', () => {
it('displays CREATOR tier correctly', () => {
mockSubscriptionTier.value = 'CREATOR'
const { container } = createComponent()
expect(container.textContent).toContain('Creator')
expect(container.textContent).toContain('Import your own LoRAs')
expect(container.textContent).toContain('7,400')
})
})
describe('credit display functionality', () => {
it('displays dynamic credit values correctly', () => {
const { container } = createComponent()
expect(container.textContent).toContain('10.00 Credits')
expect(container.textContent).toContain('5.00 Credits')
})
it('shows loading skeleton when fetching balance', () => {
mockCreditsData.isLoadingBalance = true
createComponent()
expect(
screen.getAllByRole('status', { name: 'Loading' }).length
).toBeGreaterThan(0)
})
it('hides skeleton when balance loaded', () => {
mockCreditsData.isLoadingBalance = false
createComponent()
expect(screen.queryAllByRole('status', { name: 'Loading' })).toHaveLength(
0
)
})
it('renders refill date with literal slashes', () => {
vi.useFakeTimers()
vi.stubEnv('TZ', 'UTC')
try {
mockIsActiveSubscription.value = true
const { container } = createComponent()
expect(container.textContent).toMatch(
/Included \(Refills \d{2}\/\d{2}\/\d{2}\)/
)
expect(container.textContent).not.toContain('&#x2F;')
} finally {
vi.useRealTimers()
vi.unstubAllEnvs()
}
})
})
@@ -308,6 +335,15 @@ describe('SubscriptionPanel', () => {
await userEvent.click(supportButton)
expect(mockActionsData.handleMessageSupport).toHaveBeenCalledOnce()
})
it('should call handleRefresh when refresh button is clicked', async () => {
createComponent()
const refreshButton = screen.getByRole('button', {
name: 'Refresh credits'
})
await userEvent.click(refreshButton)
expect(mockActionsData.handleRefresh).toHaveBeenCalledOnce()
})
})
describe('loading states', () => {
@@ -317,5 +353,14 @@ describe('SubscriptionPanel', () => {
const supportButton = findButtonByText('Message Support')
expect(supportButton).toBeDisabled()
})
it('should show loading state on refresh button when loading balance', () => {
mockCreditsData.isLoadingBalance = true
createComponent()
const refreshButton = screen.getByRole('button', {
name: 'Refresh credits'
})
expect(refreshButton).toBeDisabled()
})
})
})

View File

@@ -65,8 +65,100 @@
</div>
<div class="flex flex-col gap-6 pt-9 lg:flex-row">
<div class="w-full lg:max-w-md">
<CreditsTile />
<div class="flex shrink-0 flex-col">
<div class="flex flex-col gap-3">
<div
:class="
cn(
'relative flex flex-col gap-6 rounded-2xl p-5',
'bg-modal-panel-background'
)
"
>
<Button
variant="muted-textonly"
size="icon-sm"
class="absolute top-4 right-4"
:loading="isLoadingBalance"
:aria-label="$t('subscription.refreshCredits')"
@click="handleRefresh"
>
<i class="pi pi-sync text-sm text-text-secondary" />
</Button>
<div class="flex flex-col gap-2">
<div class="text-sm text-muted">
{{ $t('subscription.totalCredits') }}
</div>
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
<div v-else class="text-2xl font-bold">
{{ totalCredits }}
</div>
</div>
<!-- Credit Breakdown -->
<table class="text-sm text-muted">
<tbody>
<tr>
<td class="pr-4 text-left align-middle font-bold">
<Skeleton
v-if="isLoadingBalance"
width="5rem"
height="1rem"
/>
<span v-else>{{ includedCreditsDisplay }}</span>
</td>
<td class="align-middle" :title="creditsRemainingLabel">
{{ creditsRemainingLabel }}
</td>
</tr>
<tr>
<td class="pr-4 text-left align-middle font-bold">
<Skeleton
v-if="isLoadingBalance"
width="3rem"
height="1rem"
/>
<span v-else>{{ prepaidCredits }}</span>
</td>
<td
class="align-middle"
:title="$t('subscription.creditsYouveAdded')"
>
{{ $t('subscription.creditsYouveAdded') }}
</td>
</tr>
</tbody>
</table>
<div class="flex flex-col gap-3">
<a
href="https://platform.comfy.org/profile/usage"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-muted underline"
>
{{ $t('subscription.viewUsageHistory') }}
</a>
<Button
v-if="isActiveSubscription && isFreeTier"
variant="gradient"
class="min-h-8 w-full rounded-lg p-2 text-sm font-normal"
@click="handleUpgradeToAddCredits"
>
{{ $t('subscription.upgradeToAddCredits') }}
</Button>
<Button
v-else-if="isActiveSubscription"
variant="secondary"
class="min-h-8 rounded-lg bg-interface-menu-component-surface-selected p-2 text-sm font-normal text-text-primary"
@click="handleAddApiCredits"
>
{{ $t('subscription.addCredits') }}
</Button>
</div>
</div>
</div>
</div>
<div class="flex flex-col gap-2">
@@ -115,23 +207,26 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Skeleton from 'primevue/skeleton'
import { computed, onBeforeUnmount, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import CreditsTile from '@/platform/cloud/subscription/components/CreditsTile.vue'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import {
DEFAULT_TIER_KEY,
TIER_TO_KEY,
getTierCredits,
getTierPrice
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierBenefit } from '@/platform/cloud/subscription/utils/tierBenefits'
import { getCommonTierBenefits } from '@/platform/cloud/subscription/utils/tierBenefits'
import { cn } from '@comfyorg/tailwind-utils'
const authActions = useAuthActions()
const { t, n } = useI18n()
@@ -144,10 +239,12 @@ const {
formattedEndDate,
subscriptionTier,
subscriptionTierName,
subscriptionStatus,
isYearlySubscription
} = useSubscription()
const { show: showSubscriptionDialog } = useSubscriptionDialog()
const { show: showSubscriptionDialog, showPricingTable } =
useSubscriptionDialog()
const tierKey = computed(() => {
const tier = subscriptionTier.value
@@ -158,11 +255,89 @@ const tierPrice = computed(() =>
getTierPrice(tierKey.value, isYearlySubscription.value)
)
const refillsDate = computed(() => {
if (!subscriptionStatus.value?.renewal_date) return ''
const date = new Date(subscriptionStatus.value.renewal_date)
const day = String(date.getDate()).padStart(2, '0')
const month = String(date.getMonth() + 1).padStart(2, '0')
const year = String(date.getFullYear()).slice(-2)
return `${month}/${day}/${year}`
})
const creditsRemainingLabel = computed(() =>
isYearlySubscription.value
? t(
'subscription.creditsRemainingThisYear',
{
date: refillsDate.value
},
{
escapeParameter: false
}
)
: t(
'subscription.creditsRemainingThisMonth',
{
date: refillsDate.value
},
{
escapeParameter: false
}
)
)
const planTotalCredits = computed(() => {
const credits = getTierCredits(tierKey.value)
if (credits === null) return '—'
const total = isYearlySubscription.value ? credits * 12 : credits
return n(total)
})
const includedCreditsDisplay = computed(
() => `${monthlyBonusCredits.value} / ${planTotalCredits.value}`
)
const tierBenefits = computed((): TierBenefit[] =>
getCommonTierBenefits(tierKey.value, t, n)
)
const { handleRefresh } = useSubscriptionActions()
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
useSubscriptionCredits()
const { handleAddApiCredits, handleRefresh } = useSubscriptionActions()
function handleUpgradeToAddCredits() {
showPricingTable()
}
// Focus-based polling: refresh balance when user returns from Stripe checkout
const PENDING_TOPUP_KEY = 'pending_topup_timestamp'
const TOPUP_EXPIRY_MS = 5 * 60 * 1000 // 5 minutes
function handleWindowFocus() {
const timestampStr = localStorage.getItem(PENDING_TOPUP_KEY)
if (!timestampStr) return
const timestamp = parseInt(timestampStr, 10)
// Clear expired tracking (older than 5 minutes)
if (Date.now() - timestamp > TOPUP_EXPIRY_MS) {
localStorage.removeItem(PENDING_TOPUP_KEY)
return
}
// Refresh and clear tracking to prevent repeated calls
void handleRefresh()
localStorage.removeItem(PENDING_TOPUP_KEY)
}
onMounted(() => {
window.addEventListener('focus', handleWindowFocus)
})
onBeforeUnmount(() => {
window.removeEventListener('focus', handleWindowFocus)
})
</script>
<style scoped>

View File

@@ -81,42 +81,6 @@ describe('useBillingPlans', () => {
expect(currentPlanSlug.value).toBeNull()
})
it('populates teamCreditStops from the response', async () => {
const stops = {
default_stop_index: 2,
stops: [
{
id: 'team_700',
credits: 147_700,
monthly: { list_price_cents: 70_000, price_cents: 66_500 },
yearly: { list_price_cents: 70_000, price_cents: 63_000 }
}
]
}
mockGetBillingPlans.mockResolvedValue({
plans: [buildPlan()],
team_credit_stops: stops
})
const useBillingPlans = await importUseBillingPlans()
const { fetchPlans, teamCreditStops } = useBillingPlans()
await fetchPlans()
expect(teamCreditStops.value).toEqual(stops)
})
it('leaves teamCreditStops null when the response omits it', async () => {
mockGetBillingPlans.mockResolvedValue({ plans: [buildPlan()] })
const useBillingPlans = await importUseBillingPlans()
const { fetchPlans, teamCreditStops } = useBillingPlans()
await fetchPlans()
expect(teamCreditStops.value).toBeNull()
})
it('dedupes concurrent calls while a fetch is in flight', async () => {
let resolveFetch: (value: { plans: Plan[] }) => void = () => {}
mockGetBillingPlans.mockImplementation(

View File

@@ -1,239 +0,0 @@
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<string, string>
}))
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('strips but does not open for an empty param', async () => {
mockRouteQuery.value = { pricing: '' }
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
await loadPricingTableFromUrl()
expect(mockShowPricingTable).not.toHaveBeenCalled()
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
'pricing'
)
})
it('strips but does not open for a non-string param', async () => {
mockRouteQuery.value = { pricing: fromAny<string, unknown>(['array']) }
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
await loadPricingTableFromUrl()
expect(mockShowPricingTable).not.toHaveBeenCalled()
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
})
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'
)
})
})

View File

@@ -1,72 +0,0 @@
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 === undefined) return
// Strip any present pricing param (even ineligible or malformed values) 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.
const cleanQuery = { ...query }
delete cleanQuery.pricing
router.replace({ query: cleanQuery }).catch((error) => {
console.warn(
'[usePricingTableUrlLoader] Failed to clean URL params:',
error
)
})
clearPreservedQuery(NAMESPACE)
// Only a non-empty string value opens the table; an empty/array param just
// gets stripped above.
if (typeof param !== 'string' || !param) return
// Load members before reading the gate so the original-owner self-row is
// present. fetchMembers always awaits the request; ensureMembersLoaded can
// early-return on a cached/in-flight load and let the gate read empty members.
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
}
}

View File

@@ -9,10 +9,8 @@ import {
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { fetchWithUnifiedRemint } from '@/platform/auth/unified/remintRetry'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
@@ -56,7 +54,6 @@ function useSubscriptionInternal() {
const authStore = useAuthStore()
const { getAuthHeader } = authStore
const { flags } = useFeatureFlags()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { isLoggedIn } = useCurrentUser()
@@ -250,7 +247,7 @@ function useSubscriptionInternal() {
/**
* Whether cloud subscription mode is enabled (cloud distribution with subscription_required config).
* Use to determine which UI to show (SubscriptionPanel vs CreditsPanel).
* Use to determine which UI to show (SubscriptionPanel vs LegacyCreditsPanel).
*/
const isSubscriptionEnabled = (): boolean =>
Boolean(isCloud && window.__CONFIG__?.subscription_required)
@@ -327,12 +324,11 @@ function useSubscriptionInternal() {
async function fetchSubscriptionStatus(): Promise<CloudSubscriptionStatusResponse | null> {
const headers = await buildAuthHeaders()
const response = await fetchWithUnifiedRemint(
const response = await fetch(
buildApiUrl('/customers/cloud-subscription-status'),
{
headers
},
isCloud && flags.unifiedCloudAuthEnabled
}
)
if (!response.ok) {
@@ -418,14 +414,13 @@ function useSubscriptionInternal() {
const headers = await buildAuthHeaders()
const checkoutAttribution = await getCheckoutAttributionForCloud()
const response = await fetchWithUnifiedRemint(
const response = await fetch(
buildApiUrl('/customers/cloud-subscription-checkout'),
{
method: 'POST',
headers,
body: JSON.stringify(checkoutAttribution)
},
isCloud && flags.unifiedCloudAuthEnabled
}
)
if (!response.ok) {

View File

@@ -102,28 +102,6 @@ describe('useSubscriptionCredits', () => {
})
})
describe('numeric credit values (micros-as-cents)', () => {
it('converts the monthly and prepaid balance fields from cents to credits (×2.11)', () => {
mockBillingBalance = {
amountMicros: 500,
cloudCreditBalanceMicros: 200,
prepaidBalanceMicros: 300
}
const { monthlyBonusCreditsValue, prepaidCreditsValue } =
useSubscriptionCredits()
expect(monthlyBonusCreditsValue.value).toBe(422)
expect(prepaidCreditsValue.value).toBe(633)
})
it('defaults missing fields to zero', () => {
mockBillingBalance = { amountMicros: 100 }
const { monthlyBonusCreditsValue, prepaidCreditsValue } =
useSubscriptionCredits()
expect(monthlyBonusCreditsValue.value).toBe(0)
expect(prepaidCreditsValue.value).toBe(0)
})
})
describe('isLoadingBalance', () => {
it('should reflect billingContext.isLoading', () => {
mockBillingIsLoading = true

View File

@@ -1,10 +1,7 @@
import { computed, toValue } from 'vue'
import { useI18n } from 'vue-i18n'
import {
centsToCredits,
formatCreditsFromCents
} from '@/base/credits/comfyCredits'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import { useBillingContext } from '@/composables/billing/useBillingContext'
/**
@@ -53,23 +50,10 @@ export function useSubscriptionCredits() {
const isLoadingBalance = computed(() => toValue(billingContext.isLoading))
const creditsFromMicros = (maybeCents: number | undefined): number =>
centsToCredits(maybeCents ?? 0)
const monthlyBonusCreditsValue = computed(() =>
creditsFromMicros(toValue(billingContext.balance)?.cloudCreditBalanceMicros)
)
const prepaidCreditsValue = computed(() =>
creditsFromMicros(toValue(billingContext.balance)?.prepaidBalanceMicros)
)
return {
totalCredits,
monthlyBonusCredits,
prepaidCredits,
monthlyBonusCreditsValue,
prepaidCreditsValue,
isLoadingBalance
}
}

View File

@@ -43,6 +43,12 @@ vi.mock('@/composables/useFeatureFlags', () => ({
})
}))
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
isFreeTier: mockIsFreeTier
})
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud.value
@@ -59,7 +65,6 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isFreeTier: mockIsFreeTier,
isLegacyTeamPlan: mockIsLegacyTeamPlan
})
}))
@@ -110,8 +115,10 @@ describe('useSubscriptionDialog', () => {
expect(mockShowLayoutDialog).toHaveBeenCalled()
})
it('does not wire onChooseTeam on the unified table (personal subscribes directly)', () => {
it('uses the unified table (no onChooseTeam) when team workspaces are enabled', () => {
mockTeamWorkspacesEnabled.value = true
// Unified table is workspace-type-agnostic (Jun-5 model): same path for
// a personal-plan or team-plan workspace.
mockIsInPersonalWorkspace.value = true
const { showPricingTable } = useSubscriptionDialog()
@@ -200,43 +207,6 @@ describe('useSubscriptionDialog', () => {
})
})
describe('show', () => {
it('opens the free-tier dialog for a free-tier personal user', () => {
mockIsFreeTier.value = true
mockIsInPersonalWorkspace.value = true
const { show } = useSubscriptionDialog()
show()
expect(mockShowLayoutDialog).toHaveBeenCalledWith(
expect.objectContaining({ key: 'free-tier-info' })
)
})
it('falls back to the pricing table for a non-free-tier user', () => {
mockIsFreeTier.value = false
const { show } = useSubscriptionDialog()
show()
expect(mockShowLayoutDialog).toHaveBeenCalledWith(
expect.objectContaining({ key: 'subscription-required' })
)
})
it('falls back to the pricing table for a free-tier team workspace', () => {
mockIsFreeTier.value = true
mockIsInPersonalWorkspace.value = false
const { show } = useSubscriptionDialog()
show()
expect(mockShowLayoutDialog).toHaveBeenCalledWith(
expect.objectContaining({ key: 'subscription-required' })
)
})
})
describe('startTeamWorkspaceUpgradeFlow', () => {
it('closes existing dialogs before opening team workspace dialog', () => {
mockShowTeamWorkspacesDialog.mockResolvedValue(undefined)

View File

@@ -3,6 +3,7 @@ import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
@@ -15,7 +16,6 @@ export type SubscriptionDialogReason =
| 'subscription_required'
| 'out_of_credits'
| 'top_up_blocked'
| 'deep_link'
export interface SubscriptionDialogOptions {
reason?: SubscriptionDialogReason
@@ -33,6 +33,7 @@ export const useSubscriptionDialog = () => {
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
const { permissions } = useWorkspaceUI()
const { isFreeTier } = useSubscription()
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
@@ -158,10 +159,6 @@ export const useSubscriptionDialog = () => {
}
function show(options?: SubscriptionDialogOptions) {
// Free-tier state comes from the unified facade so it works on both the
// legacy (/customers) and workspace (/api/billing) paths. Resolved lazily
// (not at composable setup) to avoid the useBillingContext import cycle.
const { isFreeTier } = useBillingContext()
if (isFreeTier.value && workspaceStore.isInPersonalWorkspace) {
const component = defineAsyncComponent(
() =>

View File

@@ -1,76 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
getStopDiscountedMonthlyUsd,
getTeamPlanSlug,
mapApiTeamCreditStops
} from './teamPlanCreditStops'
describe('mapApiTeamCreditStops', () => {
it('derives usd, credits, discount and carries the backend id', () => {
const mapped = mapApiTeamCreditStops([
{
id: 'team_700',
credits: 147_700,
yearly: { list_price_cents: 70_000, price_cents: 63_000 }
}
])
expect(mapped).toEqual([
{
id: 'team_700',
usd: 700,
credits: 147_700,
discountPercentYearly: 10
}
])
})
it('returns a 0% discount when the list price is zero', () => {
const mapped = mapApiTeamCreditStops([
{
id: 'team_free',
credits: 0,
yearly: { list_price_cents: 0, price_cents: 0 }
}
])
expect(mapped[0].discountPercentYearly).toBe(0)
})
})
describe('getStopDiscountedMonthlyUsd', () => {
it('applies the full yearly discount for the yearly cycle', () => {
expect(
getStopDiscountedMonthlyUsd(
{ usd: 700, discountPercentYearly: 10 },
'yearly'
)
).toBe(630)
})
it('halves the discount for the monthly cycle', () => {
expect(
getStopDiscountedMonthlyUsd(
{ usd: 700, discountPercentYearly: 10 },
'monthly'
)
).toBe(665)
})
it('reads the stop discount so backend-driven stops are honored', () => {
expect(
getStopDiscountedMonthlyUsd(
{ usd: 1000, discountPercentYearly: 25 },
'yearly'
)
).toBe(750)
})
})
describe('getTeamPlanSlug', () => {
it('maps the billing cycle to the per-credit team plan slug', () => {
expect(getTeamPlanSlug('monthly')).toBe('team_per_credit_monthly')
expect(getTeamPlanSlug('yearly')).toBe('team_per_credit_annual')
})
})

View File

@@ -1,7 +1,4 @@
export interface CreditStop {
/** Backend stop identifier (e.g. "team_700"), sent on subscribe. Present for
* API-sourced stops; absent only for the hardcoded OSS / pre-deploy fallback. */
id?: string
/** Monthly subscription price in USD (pre-discount). */
usd: number
/** Monthly credit grant at this stop. */
@@ -20,9 +17,6 @@ export interface CreditStop {
/** A selected slider stop, as emitted by the pricing table's team column. */
export interface TeamPlanSelection {
/** Backend stop identifier (e.g. "team_700"), sent on subscribe. Present for
* API-sourced stops; absent only for the hardcoded OSS / pre-deploy fallback. */
id?: string
/** Pre-discount monthly price in USD (the struck-through list price). */
usd: number
/** Monthly credit grant at this stop. */
@@ -32,14 +26,17 @@ export interface TeamPlanSelection {
}
/**
* Team-plan credit-subscription slider stops — OSS / pre-deploy fallback.
* Team-plan credit-subscription slider stops.
*
* The live set comes from `GET /api/billing/plans → team_credit_stops` (mapped
* via `mapApiTeamCreditStops`); these hardcoded DES-197 breakpoints render only
* when the API doesn't supply them. The slider snaps to exactly these 5 fixed
* breakpoints — the user cannot select a value in between. The `credits` figures
* equal `usdToCredits(usd)` at the current rate (`CREDITS_PER_USD = 211`); a unit
* test guards against rate drift silently changing the designed values.
* Hardcoded per Figma DES-197 (Updates to PricingTable dialog): the team-plan
* credit slider snaps to exactly these 5 fixed breakpoints — the user cannot
* select a value in between. The `credits` figures equal `usdToCredits(usd)` at
* the current rate (`CREDITS_PER_USD = 211`); a unit test guards against rate
* drift silently changing the designed values.
*
* TODO(FE-934): once the backend slider contract lands, these stops (and their
* discount tiers) will come from `GET /api/billing/plans` instead of being
* hardcoded here.
*/
export const TEAM_PLAN_CREDIT_STOPS: readonly CreditStop[] = [
{ usd: 200, credits: 42_200, discountPercentYearly: 0 },
@@ -53,58 +50,20 @@ export const TEAM_PLAN_CREDIT_STOPS: readonly CreditStop[] = [
export const DEFAULT_TEAM_PLAN_STOP_INDEX = 2
/**
* Per-credit Team plan slug for a billing cadence (cloud catalog). The slug
* encodes the cadence; `POST /api/billing/subscribe` reads `plan_slug` +
* `team_credit_stop_id` and resolves all amounts server-side from the stop.
*/
export function getTeamPlanSlug(billingCycle: 'monthly' | 'yearly'): string {
return billingCycle === 'yearly'
? 'team_per_credit_annual'
: 'team_per_credit_monthly'
}
/**
* Map the backend `team_credit_stops` payload to the slider's `CreditStop[]`.
* The pre-discount monthly `usd` is the yearly list price; the yearly discount
* percent is derived from the struck (`list_price_cents`) vs discounted
* (`price_cents`) yearly figures. The backend `id` is carried so a selected stop
* can be sent on subscribe.
*/
export function mapApiTeamCreditStops(
stops: readonly {
id: string
credits: number
yearly: { list_price_cents: number; price_cents: number }
}[]
): CreditStop[] {
return stops.map((stop) => {
const listCents = stop.yearly.list_price_cents
const discountPercentYearly =
listCents > 0
? Math.round(((listCents - stop.yearly.price_cents) / listCents) * 100)
: 0
return {
id: stop.id,
usd: Math.round(listCents / 100),
credits: stop.credits,
discountPercentYearly
}
})
}
/**
* Discounted monthly price for a credit stop, applying the billing-cycle
* Discounted monthly price for a stop's list `usd`, applying the billing-cycle
* discount (yearly = full `discountPercentYearly`; monthly halves it). Shared by
* the slider display and the checkout confirm step so the two never drift, and
* it reads the stop's own discount so backend-driven stops are honored.
* the slider display and the checkout confirm step so the two never drift.
* Falls back to the list price when `usd` is not a known stop.
*/
export function getStopDiscountedMonthlyUsd(
stop: Pick<CreditStop, 'usd' | 'discountPercentYearly'>,
export function getDiscountedMonthlyUsd(
usd: number,
cycle: 'monthly' | 'yearly'
): number {
const stop = TEAM_PLAN_CREDIT_STOPS.find((s) => s.usd === usd)
if (!stop) return usd
const percent =
cycle === 'monthly'
? stop.discountPercentYearly / 2
: stop.discountPercentYearly
return Math.round(stop.usd * (1 - percent / 100))
return Math.round(usd * (1 - percent / 100))
}

View File

@@ -1,37 +0,0 @@
import { describe, expect, it } from 'vitest'
import { computeMonthlyUsage } from '@/platform/cloud/subscription/utils/creditsProgress'
describe('computeMonthlyUsage', () => {
it('reports the consumed portion of the monthly allowance', () => {
expect(computeMonthlyUsage(105_450, 200_000)).toEqual({
used: 94_550,
usedFraction: 0.47275
})
})
it('returns zero usage when the monthly allowance is unknown', () => {
expect(computeMonthlyUsage(100, 0)).toEqual({ used: 0, usedFraction: 0 })
})
it('treats a balance above the allowance (rollover) as nothing used', () => {
expect(computeMonthlyUsage(503_805, 253_200)).toEqual({
used: 0,
usedFraction: 0
})
})
it('caps the fill at a full bar once the allowance is exhausted', () => {
expect(computeMonthlyUsage(0, 200_000)).toEqual({
used: 200_000,
usedFraction: 1
})
})
it('caps used at the allowance when the remaining balance is negative', () => {
expect(computeMonthlyUsage(-50_000, 200_000)).toEqual({
used: 200_000,
usedFraction: 1
})
})
})

View File

@@ -1,28 +0,0 @@
export interface MonthlyCreditsUsage {
/** Credits consumed from the monthly allowance (never negative). */
used: number
/** Fraction (01) of the monthly allowance consumed — drives the bar fill. */
usedFraction: number
}
/**
* Computes monthly credit usage for the credits bar. The bar fills with the
* consumed portion of the monthly allowance; `used` clamps at zero so a balance
* that exceeds the nominal allowance (rolled-over credits) reads as nothing used.
*/
export function computeMonthlyUsage(
monthlyRemaining: number,
monthlyTotal: number
): MonthlyCreditsUsage {
if (monthlyTotal <= 0) {
return { used: 0, usedFraction: 0 }
}
const used = Math.min(
monthlyTotal,
Math.max(0, monthlyTotal - monthlyRemaining)
)
const usedFraction = Math.min(1, used / monthlyTotal)
return { used, usedFraction }
}

View File

@@ -1,21 +0,0 @@
import type { SubscriptionDuration } from '@/platform/workspace/api/workspaceApi'
import type { BillingCycle } from './subscriptionTierRank'
/** Backend plan duration `'ANNUAL'` maps to the FE's yearly billing cycle. */
export const isAnnualDuration = (
duration: SubscriptionDuration | undefined
): boolean => duration === 'ANNUAL'
/**
* Whether a checkout step renders as yearly. The preview's resolved plan
* duration wins; absent a preview (fresh subscribe with no proration) it falls
* back to the user's selected billing cycle.
*/
export const isYearlyCheckout = (
planDuration: SubscriptionDuration | undefined,
billingCycle: BillingCycle
): boolean =>
planDuration !== undefined
? isAnnualDuration(planDuration)
: billingCycle === 'yearly'

View File

@@ -1,9 +1,7 @@
import { storeToRefs } from 'pinia'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { fetchWithUnifiedRemint } from '@/platform/auth/unified/remintRetry'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { AuthStoreError, useAuthStore } from '@/stores/authStore'
@@ -72,14 +70,13 @@ export async function performSubscriptionCheckout(
}
const checkoutPayload = { ...checkoutAttribution }
const response = await fetchWithUnifiedRemint(
const response = await fetch(
`${getComfyApiBaseUrl()}/customers/cloud-subscription-checkout/${checkoutTier}`,
{
method: 'POST',
headers: { ...authHeader, 'Content-Type': 'application/json' },
body: JSON.stringify(checkoutPayload)
},
isCloud && useFeatureFlags().flags.unifiedCloudAuthEnabled
}
)
if (!response.ok) {

View File

@@ -1,93 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockIsCloud, mockSubscribe } = vi.hoisted(() => ({
mockIsCloud: { value: true },
mockSubscribe: vi.fn()
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud.value
}
}))
vi.mock('@/config/comfyApi', () => ({
getComfyPlatformBaseUrl: () => 'https://app.test'
}))
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
workspaceApi: { subscribe: mockSubscribe }
}))
import { performTeamSubscriptionCheckout } from './teamSubscriptionCheckoutUtil'
describe('performTeamSubscriptionCheckout', () => {
let assignedHref: string | undefined
beforeEach(() => {
vi.clearAllMocks()
mockIsCloud.value = true
assignedHref = undefined
Object.defineProperty(globalThis, 'location', {
configurable: true,
value: {
set href(value: string) {
assignedHref = value
}
}
})
})
it('subscribes at the stop with the yearly slug and redirects to the Stripe payment page', async () => {
mockSubscribe.mockResolvedValue({
status: 'needs_payment_method',
payment_method_url: 'https://stripe.test/pay',
billing_op_id: 'op_1'
})
await performTeamSubscriptionCheckout('team_700', 'yearly')
expect(mockSubscribe).toHaveBeenCalledWith('team_per_credit_annual', {
returnUrl: 'https://app.test/payment/success',
cancelUrl: 'https://app.test/payment/failed',
teamCreditStopId: 'team_700'
})
expect(assignedHref).toBe('https://stripe.test/pay')
})
it('uses the monthly slug and lands in the app when no Stripe step is needed', async () => {
mockSubscribe.mockResolvedValue({
status: 'subscribed',
billing_op_id: 'op_2'
})
await performTeamSubscriptionCheckout('team_1400', 'monthly')
expect(mockSubscribe).toHaveBeenCalledWith('team_per_credit_monthly', {
returnUrl: expect.any(String),
cancelUrl: expect.any(String),
teamCreditStopId: 'team_1400'
})
expect(assignedHref).toBe('/')
})
it('throws when payment is needed but no payment URL is returned', async () => {
mockSubscribe.mockResolvedValue({
status: 'needs_payment_method',
billing_op_id: 'op_3'
})
await expect(
performTeamSubscriptionCheckout('team_700', 'yearly')
).rejects.toThrow(/payment URL/)
expect(assignedHref).toBeUndefined()
})
it('does nothing off cloud', async () => {
mockIsCloud.value = false
await performTeamSubscriptionCheckout('team_700', 'yearly')
expect(mockSubscribe).not.toHaveBeenCalled()
expect(assignedHref).toBeUndefined()
})
})

View File

@@ -1,50 +0,0 @@
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import { getTeamPlanSlug } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
import { isCloud } from '@/platform/distribution/types'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import type { BillingCycle } from './subscriptionTierRank'
/**
* Direct team-plan checkout for the marketing `/cloud/subscribe?tier=team` deep
* link: subscribes to the per-credit Team plan at the chosen slider stop and
* sends the user straight to the Stripe payment page.
*
* Mirrors `performSubscriptionCheckout` (personal) but routes through the
* workspace billing endpoint (`POST /api/billing/subscribe`), because the
* per-credit Team plan lives there and the backend lets any workspace — personal
* included — subscribe to it. The slug encodes the cadence; the stop id is
* validated and priced server-side.
*
* Caller guards on `isCloud`, owns loading state, and wraps error handling. A
* `needs_payment_method` response is a full-page redirect to Stripe; the other
* statuses land back in the app, which polls the billing op to completion.
*/
export async function performTeamSubscriptionCheckout(
teamCreditStopId: string,
billingCycle: BillingCycle
): Promise<void> {
if (!isCloud) return
const planSlug = getTeamPlanSlug(billingCycle)
const response = await workspaceApi.subscribe(planSlug, {
returnUrl: `${getComfyPlatformBaseUrl()}/payment/success`,
cancelUrl: `${getComfyPlatformBaseUrl()}/payment/failed`,
teamCreditStopId
})
if (response.status === 'needs_payment_method') {
// A needs_payment_method response without a URL is unusable: surface it to
// the caller's error handling rather than silently dropping the user home
// with a subscription stuck mid-payment.
if (!response.payment_method_url) {
throw new Error(
'Team subscription needs a payment method but no payment URL was returned'
)
}
globalThis.location.href = response.payment_method_url
return
}
globalThis.location.href = '/'
}

View File

@@ -4,6 +4,5 @@ export const PRESERVED_QUERY_NAMESPACES = {
SHARE: 'share',
SHARE_AUTH: 'share_auth',
CREATE_WORKSPACE: 'create_workspace',
OAUTH: 'oauth',
PRICING: 'pricing'
OAUTH: 'oauth'
} as const

View File

@@ -133,7 +133,7 @@ export function useSettingUI(
children: []
},
component: defineAsyncComponent(
() => import('@/components/dialog/content/setting/CreditsPanel.vue')
() => import('@/components/dialog/content/setting/LegacyCreditsPanel.vue')
)
}

View File

@@ -35,8 +35,7 @@ import type {
UiButtonClickMetadata,
WorkflowCreatedMetadata,
WorkflowImportMetadata,
WorkflowSavedMetadata,
WorkspaceInviteMetadata
WorkflowSavedMetadata
} from './types'
/**
@@ -113,10 +112,6 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackApiCreditTopupSucceeded?.())
}
trackWorkspaceInviteSent(metadata: WorkspaceInviteMetadata): void {
this.dispatch((provider) => provider.trackWorkspaceInviteSent?.(metadata))
}
trackRunButton(properties: RunButtonProperties): void {
this.dispatch((provider) => provider.trackRunButton?.(properties))
}

View File

@@ -27,8 +27,7 @@ import type {
UiButtonClickMetadata,
WorkflowCreatedMetadata,
WorkflowImportMetadata,
WorkflowSavedMetadata,
WorkspaceInviteMetadata
WorkflowSavedMetadata
} from '../../types'
import { TelemetryEvents } from '../../types'
@@ -183,12 +182,6 @@ export class GtmTelemetryProvider implements TelemetryProvider {
)
}
trackWorkspaceInviteSent(metadata: WorkspaceInviteMetadata): void {
// GA4 names must be bare snake_case; the TelemetryEvents enum carries an
// `app:` prefix for Mixpanel/PostHog that dataLayer would forward verbatim.
this.pushEvent('workspace_invite_sent', metadata)
}
trackRunButton(properties: RunButtonProperties): void {
this.pushEvent('run_workflow', {
subscribe_to_run: properties.subscribe_to_run,

View File

@@ -39,8 +39,7 @@ import type {
UiButtonClickMetadata,
WorkflowCreatedMetadata,
WorkflowImportMetadata,
WorkflowSavedMetadata,
WorkspaceInviteMetadata
WorkflowSavedMetadata
} from '../../types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
@@ -259,10 +258,6 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.API_CREDIT_TOPUP_SUCCEEDED)
}
trackWorkspaceInviteSent(metadata: WorkspaceInviteMetadata): void {
this.trackEvent(TelemetryEvents.WORKSPACE_INVITE_SENT, metadata)
}
// Credit top-up tracking methods (composition with utility functions)
startTopupTracking(): void {
startTopupUtil()

View File

@@ -42,8 +42,7 @@ import type {
UiButtonClickMetadata,
WorkflowCreatedMetadata,
WorkflowImportMetadata,
WorkflowSavedMetadata,
WorkspaceInviteMetadata
WorkflowSavedMetadata
} from '../../types'
import { TelemetryEvents } from '../../types'
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
@@ -374,10 +373,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.API_CREDIT_TOPUP_SUCCEEDED)
}
trackWorkspaceInviteSent(metadata: WorkspaceInviteMetadata): void {
this.trackEvent(TelemetryEvents.WORKSPACE_INVITE_SENT, metadata)
}
trackRunButton(properties: RunButtonProperties): void {
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, properties)
}

View File

@@ -1,7 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
consumePendingTopup,
startTopupTracking,
checkForCompletedTopup,
clearTopupTracking
@@ -228,35 +227,4 @@ describe('topupTracker', () => {
)
})
})
describe('consumePendingTopup', () => {
it('returns false and clears nothing when no marker exists', () => {
mockLocalStorage.getItem.mockReturnValue(null)
expect(consumePendingTopup()).toBe(false)
expect(mockLocalStorage.removeItem).not.toHaveBeenCalled()
})
it('clears and returns true for a fresh marker', () => {
mockLocalStorage.getItem.mockReturnValue(
(Date.now() - 5 * 60 * 1000).toString()
)
expect(consumePendingTopup()).toBe(true)
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'pending_topup_timestamp'
)
})
it('clears and returns false for a marker older than 24 hours', () => {
mockLocalStorage.getItem.mockReturnValue(
(Date.now() - 25 * 60 * 60 * 1000).toString()
)
expect(consumePendingTopup()).toBe(false)
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'pending_topup_timestamp'
)
})
})
})

View File

@@ -61,16 +61,3 @@ export function checkForCompletedTopup(
export function clearTopupTracking(): void {
localStorage.removeItem(STORAGE_KEY)
}
/**
* Consume a pending top-up marker on window focus. Clears the marker and
* reports whether a non-expired purchase was awaiting a balance refresh.
*/
export function consumePendingTopup(): boolean {
const timestampStr = localStorage.getItem(STORAGE_KEY)
if (!timestampStr) return false
localStorage.removeItem(STORAGE_KEY)
const timestamp = parseInt(timestampStr, 10)
return Date.now() - timestamp <= MAX_AGE_MS
}

View File

@@ -464,11 +464,6 @@ export interface SubscriptionSuccessMetadata extends Record<string, unknown> {
ecommerce: EcommerceMetadata
}
export interface WorkspaceInviteMetadata extends Record<string, unknown> {
source: 'post_upgrade_success' | 'settings_members'
count: number
}
/**
* Telemetry provider interface for individual providers.
* All methods are optional - providers only implement what they need.
@@ -492,7 +487,6 @@ export interface TelemetryProvider {
trackAddApiCreditButtonClicked?(): void
trackApiCreditTopupButtonPurchaseClicked?(amount: number): void
trackApiCreditTopupSucceeded?(): void
trackWorkspaceInviteSent?(metadata: WorkspaceInviteMetadata): void
trackRunButton?(properties: RunButtonProperties): void
// Credit top-up tracking (composition with internal utilities)
@@ -596,7 +590,6 @@ export const TelemetryEvents = {
API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED:
'app:api_credit_topup_button_purchase_clicked',
API_CREDIT_TOPUP_SUCCEEDED: 'app:api_credit_topup_succeeded',
WORKSPACE_INVITE_SENT: 'app:workspace_invite_sent',
BEGIN_CHECKOUT: 'begin_checkout',
// Onboarding Survey

View File

@@ -9,8 +9,7 @@ const {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
interceptors: { response: { use: vi.fn() } }
delete: vi.fn()
},
mockGetAuthHeaderOrThrow: vi.fn(),
mockGetFirebaseAuthHeaderOrThrow: vi.fn()
@@ -212,27 +211,6 @@ describe('workspaceApi', () => {
{ headers: AUTH_HEADER }
)
})
it('updateMemberRole() sends PATCH /workspace/members/:userId with the role', async () => {
const updated = {
id: 'user-42',
name: 'Jane',
email: 'jane@test.comfy.org',
joined_at: '2025-01-03T00:00:00Z',
role: 'owner',
is_original_owner: false
}
mockAxiosInstance.patch.mockResolvedValue({ data: updated })
const result = await workspaceApi.updateMemberRole('user-42', 'owner')
expect(mockAxiosInstance.patch).toHaveBeenCalledWith(
'/api/workspace/members/user-42',
{ role: 'owner' },
{ headers: AUTH_HEADER }
)
expect(result).toEqual(updated)
})
})
describe('invite management', () => {
@@ -287,7 +265,7 @@ describe('workspaceApi', () => {
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
'/api/invites/abc-token/accept',
null,
{ headers: AUTH_HEADER, __skipUnifiedRemint: true }
{ headers: AUTH_HEADER }
)
expect(result).toEqual(data)
})
@@ -356,42 +334,18 @@ describe('workspaceApi', () => {
const data = { billing_op_id: 'op-1', status: 'subscribed' }
mockAxiosInstance.post.mockResolvedValue({ data })
const result = await workspaceApi.subscribe('pro-monthly', {
returnUrl: 'https://return.url',
cancelUrl: 'https://cancel.url'
})
const result = await workspaceApi.subscribe(
'pro-monthly',
'https://return.url',
'https://cancel.url'
)
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
'/api/billing/subscribe',
{
plan_slug: 'pro-monthly',
return_url: 'https://return.url',
cancel_url: 'https://cancel.url',
team_credit_stop_id: undefined,
billing_cycle: undefined
},
{ headers: AUTH_HEADER }
)
expect(result).toEqual(data)
})
it('subscribe() sends team_credit_stop_id and billing_cycle for team plans', async () => {
const data = { billing_op_id: 'op-1b', status: 'needs_payment_method' }
mockAxiosInstance.post.mockResolvedValue({ data })
const result = await workspaceApi.subscribe('team_per_credit_annual', {
teamCreditStopId: 'team_700',
billingCycle: 'yearly'
})
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
'/api/billing/subscribe',
{
plan_slug: 'team_per_credit_annual',
return_url: undefined,
cancel_url: undefined,
team_credit_stop_id: 'team_700',
billing_cycle: 'yearly'
cancel_url: 'https://cancel.url'
},
{ headers: AUTH_HEADER }
)

View File

@@ -1,6 +1,5 @@
import axios from 'axios'
import { attachUnifiedRemintInterceptor } from '@/platform/auth/unified/remintRetry'
import type { SubscriptionTier } from '@/platform/cloud/subscription/constants/tierPricing'
import type {
WorkspaceId,
@@ -150,32 +149,13 @@ type SubscriptionTransitionType =
interface PreviewSubscribeRequest {
plan_slug: string
team_credit_stop_id?: string
billing_cycle?: SubscribeBillingCycle
}
type SubscribeBillingCycle = 'monthly' | 'yearly'
interface SubscribeRequest {
plan_slug: string
idempotency_key?: string
return_url?: string
cancel_url?: string
/** Required for the per-credit Team plan; selects the slider stop. */
team_credit_stop_id?: string
billing_cycle?: SubscribeBillingCycle
}
export interface SubscribeOptions {
returnUrl?: string
cancelUrl?: string
teamCreditStopId?: string
billingCycle?: SubscribeBillingCycle
}
export interface PreviewSubscribeOptions {
teamCreditStopId?: string
billingCycle?: SubscribeBillingCycle
}
type SubscribeStatus = 'subscribed' | 'needs_payment_method' | 'pending_payment'
@@ -341,9 +321,6 @@ const workspaceApiClient = axios.create({
}
})
// acceptInvite opts out via __skipUnifiedRemint (it is deliberately Firebase-authed).
attachUnifiedRemintInterceptor(workspaceApiClient)
async function getAuthHeaderOrThrow() {
return useAuthStore().getAuthHeaderOrThrow()
}
@@ -480,24 +457,6 @@ export const workspaceApi = {
}
},
/**
* Change a member's role (member ↔ owner).
* PATCH /api/workspace/members/:userId
*/
async updateMemberRole(userId: UserId, role: WorkspaceRole): Promise<Member> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.patch<Member>(
api.apiURL(`/workspace/members/${userId}`),
{ role },
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* List pending invites for the workspace.
* GET /api/workspace/invites
@@ -560,7 +519,7 @@ export const workspaceApi = {
const response = await workspaceApiClient.post<AcceptInviteResponse>(
api.apiURL(`/invites/${token}/accept`),
null,
{ headers, __skipUnifiedRemint: true }
{ headers }
)
return response.data
} catch (err) {
@@ -623,19 +582,12 @@ export const workspaceApi = {
* Preview subscription change
* POST /api/billing/preview-subscribe
*/
async previewSubscribe(
planSlug: string,
options: PreviewSubscribeOptions = {}
): Promise<PreviewSubscribeResponse> {
async previewSubscribe(planSlug: string): Promise<PreviewSubscribeResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.post<PreviewSubscribeResponse>(
api.apiURL('/billing/preview-subscribe'),
{
plan_slug: planSlug,
team_credit_stop_id: options.teamCreditStopId,
billing_cycle: options.billingCycle
} satisfies PreviewSubscribeRequest,
{ plan_slug: planSlug } satisfies PreviewSubscribeRequest,
{ headers }
)
return response.data
@@ -650,7 +602,8 @@ export const workspaceApi = {
*/
async subscribe(
planSlug: string,
options: SubscribeOptions = {}
returnUrl?: string,
cancelUrl?: string
): Promise<SubscribeResponse> {
const headers = await getAuthHeaderOrThrow()
try {
@@ -658,10 +611,8 @@ export const workspaceApi = {
api.apiURL('/billing/subscribe'),
{
plan_slug: planSlug,
return_url: options.returnUrl,
cancel_url: options.cancelUrl,
team_credit_stop_id: options.teamCreditStopId,
billing_cycle: options.billingCycle
return_url: returnUrl,
cancel_url: cancelUrl
} satisfies SubscribeRequest,
{ headers }
)

View File

@@ -1,192 +0,0 @@
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import InviteMembersForm from './InviteMembersForm.vue'
import type { PendingInvite } from '@/platform/workspace/stores/teamWorkspaceStore'
const { mockCreateInvite, mockToastAdd, mockTrackInviteSent } = vi.hoisted(
() => ({
mockCreateInvite: vi.fn(),
mockToastAdd: vi.fn(),
mockTrackInviteSent: vi.fn()
})
)
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
createInvite: mockCreateInvite as (email: string) => Promise<PendingInvite>
})
}))
vi.mock('primevue/usetoast', () => ({
useToast: () => ({
add: mockToastAdd
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackWorkspaceInviteSent: mockTrackInviteSent
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
function pendingInviteFor(email: string): PendingInvite {
return {
id: `inv-${email}`,
email,
inviteDate: new Date(0),
expiryDate: new Date(0)
}
}
function renderForm(props: Record<string, unknown> = {}) {
const user = userEvent.setup()
const result = render(InviteMembersForm, {
props: {
submitLabel: 'Send invites',
placeholder: 'Enter emails',
source: 'post_upgrade_success',
...props
},
global: { plugins: [i18n] }
})
return { ...result, user }
}
function emailInput() {
return screen.getByRole('textbox')
}
function submitButton() {
return screen.getByRole('button', { name: 'Send invites' })
}
describe('InviteMembersForm', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCreateInvite.mockImplementation(async (email: string) =>
pendingInviteFor(email)
)
})
it('turns comma- and enter-delimited input into chips', async () => {
const { user } = renderForm()
await user.type(emailInput(), 'a@b.com,')
await user.type(emailInput(), 'c@d.com{Enter}')
expect(screen.getByText('a@b.com')).toBeInTheDocument()
expect(screen.getByText('c@d.com')).toBeInTheDocument()
})
it('disables submit with no chips and flags invalid emails', async () => {
const { user } = renderForm()
expect(submitButton()).toBeDisabled()
await user.type(emailInput(), 'not-an-email{Enter}')
expect(screen.getByText('not-an-email')).toBeInTheDocument()
expect(
screen.getByText('workspacePanel.inviteMemberDialog.invalidEmailCount')
).toBeInTheDocument()
expect(submitButton()).toBeDisabled()
})
it('creates an invite per email, tracks telemetry, and emits submitted', async () => {
const { user, emitted } = renderForm()
await user.type(emailInput(), 'a@b.com,c@d.com{Enter}')
await user.click(submitButton())
await waitFor(() => expect(mockCreateInvite).toHaveBeenCalledTimes(2))
expect(mockCreateInvite).toHaveBeenCalledWith('a@b.com')
expect(mockCreateInvite).toHaveBeenCalledWith('c@d.com')
expect(mockTrackInviteSent).toHaveBeenCalledWith({
source: 'post_upgrade_success',
count: 2
})
expect(emitted().submitted).toEqual([[['a@b.com', 'c@d.com']]])
})
it('keeps failed emails as chips, toasts, and emits the invited subset on partial failure', async () => {
mockCreateInvite.mockImplementation(async (email: string) => {
if (email === 'fail@x.com') throw new Error('nope')
return pendingInviteFor(email)
})
const { user, emitted } = renderForm()
await user.type(emailInput(), 'ok@x.com,fail@x.com{Enter}')
await user.click(submitButton())
await waitFor(() => expect(mockCreateInvite).toHaveBeenCalledTimes(2))
expect(screen.getByText('fail@x.com')).toBeInTheDocument()
expect(screen.queryByText('ok@x.com')).not.toBeInTheDocument()
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
expect(emitted().submitted).toEqual([[['ok@x.com']]])
expect(mockTrackInviteSent).toHaveBeenCalledWith({
source: 'post_upgrade_success',
count: 1
})
})
it('keeps all chips, toasts, and emits nothing when every invite fails', async () => {
mockCreateInvite.mockRejectedValue(new Error('nope'))
const { user, emitted } = renderForm()
await user.type(emailInput(), 'a@b.com,c@d.com{Enter}')
await user.click(submitButton())
await waitFor(() => expect(mockCreateInvite).toHaveBeenCalledTimes(2))
expect(screen.getByText('a@b.com')).toBeInTheDocument()
expect(screen.getByText('c@d.com')).toBeInTheDocument()
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
expect(emitted().submitted).toBeUndefined()
expect(mockTrackInviteSent).not.toHaveBeenCalled()
})
it('caps the number of chips at maxSeats', async () => {
const { user } = renderForm({ maxSeats: 2 })
await user.type(emailInput(), 'a@b.com,b@b.com,c@b.com{Enter}')
expect(screen.getByText('a@b.com')).toBeInTheDocument()
expect(screen.getByText('b@b.com')).toBeInTheDocument()
expect(screen.queryByText('c@b.com')).not.toBeInTheDocument()
expect(
screen.getByText('workspacePanel.inviteMemberDialog.seatLimitReached')
).toBeInTheDocument()
})
it('emits cancel when a cancel label is provided', async () => {
const { user, emitted } = renderForm({ cancelLabel: 'Cancel' })
await user.click(screen.getByRole('button', { name: 'Cancel' }))
expect(emitted().cancel).toBeTruthy()
expect(mockCreateInvite).not.toHaveBeenCalled()
})
it('hides the built-in submit row when showSubmit is false', () => {
renderForm({ showSubmit: false })
expect(
screen.queryByRole('button', { name: 'Send invites' })
).not.toBeInTheDocument()
})
})

View File

@@ -1,220 +0,0 @@
<template>
<div class="flex flex-col gap-2">
<TagsInput
always-editing
add-on-paste
add-on-blur
:delimiter="EMAIL_DELIMITER"
:convert-value="normalizeEmail"
:model-value="emails"
class="min-h-10 w-full bg-tertiary-background px-3 focus-within:bg-tertiary-background hover:bg-tertiary-background-hover"
@update:model-value="onEmailsUpdate"
>
<TagsInputItem
v-for="email in emails"
:key="email"
:value="email"
:class="
cn('rounded-full', !isValidEmail(email) && 'bg-danger/20 text-danger')
"
>
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput
:auto-focus="autoFocus"
class="min-w-0 text-sm"
:aria-label="placeholder"
:aria-describedby="describedBy"
:placeholder="emails.length === 0 ? placeholder : undefined"
/>
</TagsInput>
<p
v-if="invalidEmails.length > 0"
:id="invalidEmailsHintId"
role="alert"
class="text-danger m-0 text-xs"
>
{{
$t(
'workspacePanel.inviteMemberDialog.invalidEmailCount',
invalidEmails.length
)
}}
</p>
<p
v-if="isAtSeatLimit"
:id="seatLimitHintId"
aria-live="polite"
class="m-0 text-xs text-muted-foreground"
>
{{ $t('workspacePanel.inviteMemberDialog.seatLimitReached', maxSeats) }}
</p>
<div
v-if="showSubmit"
:class="
cn('flex', cancelLabel ? 'items-center justify-end gap-4' : 'flex-col')
"
>
<Button
v-if="cancelLabel"
variant="muted-textonly"
size="lg"
@click="$emit('cancel')"
>
{{ cancelLabel }}
</Button>
<Button
variant="secondary"
size="lg"
:class="cn(!cancelLabel && 'w-full rounded-lg')"
:loading
:disabled="!canSubmit"
:aria-busy="loading"
:aria-label="loading ? $t('g.loading') : submitLabel"
@click="onSubmit"
>
{{ submitLabel }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { computed, ref, useId } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import TagsInput from '@/components/ui/tags-input/TagsInput.vue'
import TagsInputInput from '@/components/ui/tags-input/TagsInputInput.vue'
import TagsInputItem from '@/components/ui/tags-input/TagsInputItem.vue'
import TagsInputItemDelete from '@/components/ui/tags-input/TagsInputItemDelete.vue'
import TagsInputItemText from '@/components/ui/tags-input/TagsInputItemText.vue'
import { useTelemetry } from '@/platform/telemetry'
import type { WorkspaceInviteMetadata } from '@/platform/telemetry/types'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import {
EMAIL_DELIMITER,
isValidEmail,
normalizeEmail,
sanitizeInviteEmails
} from '@/platform/workspace/utils/inviteEmails'
import { cn } from '@comfyorg/tailwind-utils'
const {
submitLabel,
placeholder,
source,
cancelLabel,
maxSeats = Number.POSITIVE_INFINITY,
showSubmit = true,
autoFocus = false
} = defineProps<{
submitLabel: string
placeholder: string
source: WorkspaceInviteMetadata['source']
cancelLabel?: string
maxSeats?: number
/** Hide the built-in submit row so a parent can place the action elsewhere
* (e.g. the team-upgrade success footer); drive it via the exposed submit. */
showSubmit?: boolean
/** Focus the email input on mount. Off by default so an embedding dialog
* keeps control of its own focus order. */
autoFocus?: boolean
}>()
const emit = defineEmits<{
submitted: [emails: string[]]
cancel: []
}>()
const { t } = useI18n()
const toast = useToast()
const telemetry = useTelemetry()
const workspaceStore = useTeamWorkspaceStore()
const emails = ref<string[]>([])
const loading = ref(false)
const invalidEmailsHintId = useId()
const seatLimitHintId = useId()
const invalidEmails = computed(() =>
emails.value.filter((email) => !isValidEmail(email))
)
const isAtSeatLimit = computed(() => emails.value.length >= maxSeats)
const canSubmit = computed(
() =>
emails.value.length > 0 &&
emails.value.length <= maxSeats &&
invalidEmails.value.length === 0
)
const describedBy = computed(
() =>
[
invalidEmails.value.length > 0 ? invalidEmailsHintId : undefined,
isAtSeatLimit.value ? seatLimitHintId : undefined
]
.filter(Boolean)
.join(' ') || undefined
)
function onEmailsUpdate(value: string[]) {
emails.value = sanitizeInviteEmails(value, maxSeats)
}
async function onSubmit() {
if (loading.value) return
loading.value = true
if (!canSubmit.value) {
loading.value = false
return
}
try {
const emailSnapshot = [...emails.value]
const results = await Promise.allSettled(
emailSnapshot.map((email) => workspaceStore.createInvite(email))
)
const failedEmails = emailSnapshot.filter(
(_, index) => results[index].status === 'rejected'
)
const invitedCount = emailSnapshot.length - failedEmails.length
if (invitedCount > 0) {
telemetry?.trackWorkspaceInviteSent({ source, count: invitedCount })
emit(
'submitted',
emailSnapshot.filter((email) => !failedEmails.includes(email))
)
}
if (failedEmails.length === 0) return
emails.value = failedEmails
toast.add({
severity: 'error',
summary: t(
'workspacePanel.inviteMemberDialog.failedCount',
failedEmails.length
),
life: 5000
})
} finally {
loading.value = false
}
}
defineExpose({
submit: onSubmit,
get canSubmit() {
return canSubmit.value
},
get loading() {
return loading.value
}
})
</script>

View File

@@ -2,42 +2,8 @@ import userEvent from '@testing-library/user-event'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import type {
PreviewSubscribeResponse,
SubscriptionDuration
} from '@/platform/workspace/api/workspaceApi'
import SubscriptionAddPaymentPreviewWorkspace from './SubscriptionAddPaymentPreviewWorkspace.vue'
function previewFixture(
duration: SubscriptionDuration,
priceCents: number
): PreviewSubscribeResponse {
return {
allowed: true,
transition_type: 'new_subscription',
effective_at: '2026-06-19T00:00:00Z',
is_immediate: true,
cost_today_cents: priceCents,
cost_next_period_cents: priceCents,
credits_today_cents: 0,
credits_next_period_cents: 0,
new_plan: {
slug: 'creator',
tier: 'CREATOR',
duration,
price_cents: priceCents,
credits_cents: 0,
seat_summary: {
seat_count: 1,
total_cost_cents: priceCents,
total_credits_cents: 0
},
period_end: '2027-06-19T00:00:00Z'
}
}
}
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
@@ -76,68 +42,6 @@ describe('SubscriptionAddPaymentPreviewWorkspace', () => {
expect(screen.getByText('$380.00')).toBeTruthy()
})
it('shows the monthly-equivalent price and annual total for a yearly preview', () => {
render(SubscriptionAddPaymentPreviewWorkspace, {
props: {
tierKey: 'creator',
billingCycle: 'yearly',
previewData: previewFixture('ANNUAL', 33_600)
},
global: globalOptions
})
expect(screen.getByText('subscription.usdPerMonth')).toBeTruthy()
expect(screen.getByText('$28')).toBeTruthy()
expect(screen.getByText('subscription.billedYearly')).toBeTruthy()
expect(screen.getByText('$336.00')).toBeTruthy()
expect(
screen.getByText('subscription.preview.eachYearCreditsRefill')
).toBeTruthy()
expect(screen.getByText('88,800')).toBeTruthy()
})
it('divides the yearly price by twelve in the fallback path', () => {
render(SubscriptionAddPaymentPreviewWorkspace, {
props: { tierKey: 'creator', billingCycle: 'yearly' },
global: globalOptions
})
expect(screen.getByText('$28')).toBeTruthy()
expect(screen.getByText('subscription.billedYearly')).toBeTruthy()
expect(screen.getByText('$336.00')).toBeTruthy()
})
it('omits the billed-yearly note for a monthly subscription', () => {
render(SubscriptionAddPaymentPreviewWorkspace, {
props: {
tierKey: 'creator',
billingCycle: 'monthly',
previewData: previewFixture('MONTHLY', 3_500)
},
global: globalOptions
})
expect(screen.getByText('$35')).toBeTruthy()
expect(screen.getByText('subscription.billedMonthly')).toBeTruthy()
expect(screen.queryByText('subscription.billedYearly')).toBeNull()
expect(
screen.getByText('subscription.preview.eachMonthCreditsRefill')
).toBeTruthy()
expect(
screen.queryByText('subscription.preview.eachYearCreditsRefill')
).toBeNull()
})
it('shows the annual total for a yearly team plan', () => {
render(SubscriptionAddPaymentPreviewWorkspace, {
props: {
billingCycle: 'yearly',
teamPlan: { usd: 400, credits: 84_400, discountedUsd: 380 }
},
global: globalOptions
})
expect(screen.getByText('$380')).toBeTruthy()
expect(screen.getByText('subscription.billedYearly')).toBeTruthy()
expect(screen.getByText('$4560.00')).toBeTruthy()
})
it('emits addCreditCard from the team confirm CTA', async () => {
const { emitted } = render(SubscriptionAddPaymentPreviewWorkspace, {
props: { teamPlan: { usd: 400, credits: 84_400, discountedUsd: 380 } },

View File

@@ -19,13 +19,13 @@
{{ $t('subscription.usdPerMonth') }}
</span>
</div>
<span class="text-muted-foreground">
{{
isYearly
? $t('subscription.billedYearly', { total: annualTotalFormatted })
: $t('subscription.billedMonthly')
}}
</span>
<div
v-if="teamPlan"
class="flex items-center gap-1 text-sm text-muted-foreground"
>
<i class="icon-[comfy--credits] size-3.5 shrink-0 bg-amber-400" />
<span>{{ displayCredits }} {{ $t('subscription.perMonth') }}</span>
</div>
<span class="text-muted-foreground">
{{ $t('subscription.preview.startingToday') }}
</span>
@@ -35,12 +35,12 @@
<div class="flex flex-col gap-3 pt-16 pb-8">
<div class="flex items-center justify-between">
<span class="text-base-foreground">
{{ $t(creditsRefillLabelKey) }}
{{ $t('subscription.preview.eachMonthCreditsRefill') }}
</span>
<div class="flex items-center gap-1">
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
<span class="font-bold text-base-foreground">
{{ refillCredits }}
{{ displayCredits }}
</span>
</div>
</div>
@@ -138,7 +138,7 @@
<!-- Add Credit Card Button -->
<Button
variant="tertiary"
variant="secondary"
size="lg"
class="w-full rounded-lg"
:loading="isLoading"
@@ -171,7 +171,6 @@ import {
getTierPrice
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import { isYearlyCheckout } from '@/platform/cloud/subscription/utils/planDuration'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
import { cn } from '@comfyorg/tailwind-utils'
@@ -211,39 +210,16 @@ const tierName = computed(() =>
: t(`subscription.tiers.${tierKey}.name`)
)
const isYearly = computed(() =>
isYearlyCheckout(previewData?.new_plan.duration, billingCycle)
)
const displayPrice = computed(() => {
if (teamPlan) return teamPlan.discountedUsd
if (previewData?.new_plan) {
const cents = previewData.new_plan.price_cents
return ((isYearly.value ? cents / 12 : cents) / 100).toFixed(0)
return (previewData.new_plan.price_cents / 100).toFixed(0)
}
return tierKey ? getTierPrice(tierKey, isYearly.value) : 0
return tierKey ? getTierPrice(tierKey, billingCycle === 'yearly') : 0
})
const annualTotalUsd = computed(() => {
if (teamPlan) return teamPlan.discountedUsd * 12
if (previewData?.new_plan) return previewData.new_plan.price_cents / 100
return tierKey ? getTierPrice(tierKey, true) * 12 : 0
})
const annualTotalFormatted = computed(() => `$${n(annualTotalUsd.value)}`)
const monthlyCredits = computed(() =>
teamPlan ? teamPlan.credits : tierKey ? (getTierCredits(tierKey) ?? 0) : 0
)
const refillCredits = computed(() =>
n(isYearly.value ? monthlyCredits.value * 12 : monthlyCredits.value)
)
const creditsRefillLabelKey = computed(() =>
isYearly.value
? 'subscription.preview.eachYearCreditsRefill'
: 'subscription.preview.eachMonthCreditsRefill'
const displayCredits = computed(() =>
n(teamPlan ? teamPlan.credits : tierKey ? (getTierCredits(tierKey) ?? 0) : 0)
)
const teamPerks = computed(() => [
@@ -259,18 +235,16 @@ const hasCustomLoRAs = computed(() =>
const maxDuration = computed(() => t(`subscription.maxDuration.${tierKey}`))
const totalDueToday = computed(() => {
if (teamPlan) {
const total = isYearly.value
? teamPlan.discountedUsd * 12
: teamPlan.discountedUsd
return total.toFixed(2)
}
if (teamPlan) return teamPlan.discountedUsd.toFixed(2)
if (previewData) {
return (previewData.cost_today_cents / 100).toFixed(2)
}
if (!tierKey) return '0.00'
const priceValue = getTierPrice(tierKey, isYearly.value)
return (isYearly.value ? priceValue * 12 : priceValue).toFixed(2)
const priceValue = getTierPrice(tierKey, billingCycle === 'yearly')
if (billingCycle === 'yearly') {
return (priceValue * 12).toFixed(2)
}
return priceValue.toFixed(2)
})
const nextPaymentDate = computed(() => {

View File

@@ -10,10 +10,8 @@ type PreviewPlanInfo = PreviewSubscribeResponse['new_plan']
/**
* Checkout steps of the unified subscription dialog (FE-934): the
* "Confirm your payment" (new subscription), the single-plan plan-change
* confirm (immediate upgrade / scheduled downgrade), and the success screen.
* Driven by props (no API in Storybook). `price_cents` is the full
* billing-period total — yearly headlines divide it by 12.
* "Confirm your payment" / "Confirm your plan change" preview screens and the
* "You're all set" success screen. Driven by props (no API in Storybook).
*/
const meta: Meta = {
title: 'Components/SubscriptionCheckoutSteps',
@@ -23,49 +21,38 @@ const meta: Meta = {
export default meta
type Story = StoryObj
const TODAY = '2026-06-19T00:00:00Z'
const NEXT_YEAR = '2027-06-28T00:00:00Z'
const PERIOD_END = '2027-06-28T00:00:00Z'
function plan(
tier: PreviewPlanInfo['tier'],
duration: PreviewPlanInfo['duration'],
priceCents: number,
periodEnd: string
): PreviewPlanInfo {
return {
slug: `${tier.toLowerCase()}-${duration.toLowerCase()}`,
tier,
duration,
price_cents: priceCents,
credits_cents: 0,
seat_summary: {
seat_count: 1,
total_cost_cents: priceCents,
total_credits_cents: 0
},
period_end: periodEnd
}
const creatorPlan: PreviewPlanInfo = {
slug: 'creator-annual',
tier: 'CREATOR',
duration: 'ANNUAL',
price_cents: 2800,
credits_cents: 740000,
seat_summary: {
seat_count: 1,
total_cost_cents: 2800,
total_credits_cents: 740000
},
period_end: '2027-07-10T00:00:00Z'
}
const creatorMonthly = plan('CREATOR', 'MONTHLY', 3500, NEXT_YEAR)
const creatorAnnual = plan('CREATOR', 'ANNUAL', 33_600, NEXT_YEAR)
const proMonthly = plan('PRO', 'MONTHLY', 10_000, PERIOD_END)
const proPlan: PreviewPlanInfo = {
slug: 'pro-annual',
tier: 'PRO',
duration: 'ANNUAL',
price_cents: 8000,
credits_cents: 2110000,
seat_summary: {
seat_count: 1,
total_cost_cents: 8000,
total_credits_cents: 2110000
},
period_end: '2026-07-10T00:00:00Z'
}
const shell =
'<div class="mx-auto flex h-[680px] w-[460px] flex-col rounded-2xl border border-border-default bg-secondary-background p-12">'
function transitionStory(previewData: PreviewSubscribeResponse): Story {
return {
render: () => ({
components: { SubscriptionTransitionPreviewWorkspace },
data: () => ({ previewData }),
template: `${shell}<SubscriptionTransitionPreviewWorkspace :preview-data="previewData" /></div>`
})
}
}
/** New subscription — "Confirm your payment" (AddPayment, Creator yearly). */
/** New subscription — "Confirm your payment" (AddPayment preview). */
export const ConfirmNewSubscription: Story = {
render: () => ({
components: { SubscriptionAddPaymentPreviewWorkspace },
@@ -73,13 +60,13 @@ export const ConfirmNewSubscription: Story = {
previewData: {
allowed: true,
transition_type: 'new_subscription',
effective_at: TODAY,
effective_at: '2026-07-10T00:00:00Z',
is_immediate: true,
cost_today_cents: 33_600,
cost_next_period_cents: 33_600,
credits_today_cents: 0,
credits_next_period_cents: 0,
new_plan: creatorAnnual
cost_today_cents: 2800,
cost_next_period_cents: 2800,
credits_today_cents: 740000,
credits_next_period_cents: 740000,
new_plan: creatorPlan
} satisfies PreviewSubscribeResponse
}),
template: `${shell}<SubscriptionAddPaymentPreviewWorkspace tier-key="creator" billing-cycle="yearly" :preview-data="previewData" /></div>`
@@ -90,101 +77,33 @@ export const ConfirmNewSubscription: Story = {
export const ConfirmTeamSubscription: Story = {
render: () => ({
components: { SubscriptionAddPaymentPreviewWorkspace },
data: () => ({
teamPlan: { usd: 700, credits: 147_700, discountedUsd: 630 }
}),
template: `${shell}<SubscriptionAddPaymentPreviewWorkspace :team-plan="teamPlan" billing-cycle="yearly" /></div>`
data: () => ({ teamPlan: { usd: 400, credits: 84_400 } }),
template: `${shell}<SubscriptionAddPaymentPreviewWorkspace :team-plan="teamPlan" /></div>`
})
}
/**
* Team credit-commit change — team_700 → team_1400 monthly (prorated). Plan name
* and refill credits come from the slider stop; the proration money is driven by
* previewData (cost_today_cents).
*/
export const ChangeTeamCreditCommit: Story = {
/** Plan change — "Confirm your plan change" (Transition preview, Pro → Creator). */
export const ConfirmPlanChange: Story = {
render: () => ({
components: { SubscriptionTransitionPreviewWorkspace },
data: () => ({
teamPlan: {
id: 'team_1400',
usd: 1400,
credits: 295_400,
discountedUsd: 1295
},
previewData: {
allowed: true,
transition_type: 'upgrade',
effective_at: TODAY,
is_immediate: true,
cost_today_cents: 105_000,
cost_next_period_cents: 140_000,
transition_type: 'downgrade',
effective_at: '2026-07-10T00:00:00Z',
is_immediate: false,
cost_today_cents: 0,
cost_next_period_cents: 2800,
credits_today_cents: 0,
credits_next_period_cents: 0,
current_plan: plan('PRO', 'MONTHLY', 70_000, NEXT_YEAR),
new_plan: plan('PRO', 'MONTHLY', 140_000, NEXT_YEAR)
credits_next_period_cents: 740000,
current_plan: proPlan,
new_plan: creatorPlan
} satisfies PreviewSubscribeResponse
}),
template: `${shell}<SubscriptionTransitionPreviewWorkspace :team-plan="teamPlan" :preview-data="previewData" /></div>`
template: `${shell}<SubscriptionTransitionPreviewWorkspace :preview-data="previewData" /></div>`
})
}
/** Immediate upgrade — Creator monthly → yearly (cadence change, prorated). */
export const UpgradeCadenceYearly: Story = transitionStory({
allowed: true,
transition_type: 'duration_change',
effective_at: TODAY,
is_immediate: true,
cost_today_cents: 31_850,
cost_next_period_cents: 33_600,
credits_today_cents: 0,
credits_next_period_cents: 0,
current_plan: creatorMonthly,
new_plan: creatorAnnual
} satisfies PreviewSubscribeResponse)
/** Immediate upgrade — Creator → Pro monthly (tier change, prorated). */
export const UpgradeTier: Story = transitionStory({
allowed: true,
transition_type: 'upgrade',
effective_at: TODAY,
is_immediate: true,
cost_today_cents: 8250,
cost_next_period_cents: 10_000,
credits_today_cents: 0,
credits_next_period_cents: 0,
current_plan: creatorMonthly,
new_plan: proMonthly
} satisfies PreviewSubscribeResponse)
/** Scheduled downgrade — Pro → Creator monthly (effective at period end). */
export const DowngradeTier: Story = transitionStory({
allowed: true,
transition_type: 'downgrade',
effective_at: PERIOD_END,
is_immediate: false,
cost_today_cents: 0,
cost_next_period_cents: 3500,
credits_today_cents: 0,
credits_next_period_cents: 0,
current_plan: proMonthly,
new_plan: creatorMonthly
} satisfies PreviewSubscribeResponse)
/** Scheduled downgrade — Creator yearly → monthly (cadence, period end). */
export const DowngradeCadenceMonthly: Story = transitionStory({
allowed: true,
transition_type: 'duration_change',
effective_at: PERIOD_END,
is_immediate: false,
cost_today_cents: 0,
cost_next_period_cents: 3500,
credits_today_cents: 0,
credits_next_period_cents: 0,
current_plan: plan('CREATOR', 'ANNUAL', 33_600, PERIOD_END),
new_plan: creatorMonthly
} satisfies PreviewSubscribeResponse)
/** Success — "You're all set". */
export const SuccessAllSet: Story = {
render: () => ({
@@ -193,29 +112,15 @@ export const SuccessAllSet: Story = {
previewData: {
allowed: true,
transition_type: 'new_subscription',
effective_at: TODAY,
effective_at: '2026-07-10T00:00:00Z',
is_immediate: true,
cost_today_cents: 33_600,
cost_next_period_cents: 33_600,
credits_today_cents: 0,
credits_next_period_cents: 0,
new_plan: creatorAnnual
cost_today_cents: 2800,
cost_next_period_cents: 2800,
credits_today_cents: 740000,
credits_next_period_cents: 740000,
new_plan: creatorPlan
} satisfies PreviewSubscribeResponse
}),
template: `${shell}<SubscriptionSuccessWorkspace tier-key="creator" :preview-data="previewData" /></div>`
})
}
/**
* Team success — "You're all set" with the inline "Invite your team" block
* (FE-965 / DES-394). Team-only: gated on a team plan + teamWorkspacesEnabled.
*/
export const TeamSuccessWithInvite: Story = {
render: () => ({
components: { SubscriptionSuccessWorkspace },
data: () => ({
teamPlan: { usd: 700, credits: 147_700, discountedUsd: 630 }
}),
template: `${shell}<SubscriptionSuccessWorkspace :team-plan="teamPlan" is-team /></div>`
})
}

View File

@@ -179,8 +179,101 @@
</div>
<div class="flex flex-col gap-6 pt-6 lg:flex-row lg:items-stretch">
<div class="w-full lg:max-w-md">
<CreditsTile :zero-state="showZeroState" />
<div class="flex flex-col">
<div class="flex h-full flex-col gap-3">
<div
class="relative flex h-full flex-col justify-between gap-6 rounded-2xl bg-secondary-background p-5"
>
<Button
variant="muted-textonly"
size="icon-sm"
class="absolute top-4 right-4"
:loading="isLoadingBalance"
@click="handleRefresh"
>
<i class="pi pi-sync text-sm text-text-secondary" />
</Button>
<div class="flex flex-col gap-2">
<div class="text-sm text-muted">
{{ $t('subscription.totalCredits') }}
</div>
<Skeleton
v-if="isLoadingBalance"
width="8rem"
height="2rem"
/>
<div v-else class="text-2xl font-bold">
{{ showZeroState ? '0' : totalCredits }}
</div>
</div>
<!-- Credit Breakdown -->
<table class="text-sm text-muted">
<tbody>
<tr>
<td class="pr-4 text-left align-middle font-bold">
<Skeleton
v-if="isLoadingBalance"
width="5rem"
height="1rem"
/>
<span v-else>{{
showZeroState ? '0 / 0' : includedCreditsDisplay
}}</span>
</td>
<td class="align-middle" :title="creditsRemainingLabel">
{{ creditsRemainingLabel }}
</td>
</tr>
<tr>
<td class="pr-4 text-left align-middle font-bold">
<Skeleton
v-if="isLoadingBalance"
width="3rem"
height="1rem"
/>
<span v-else>{{
showZeroState ? '0' : prepaidCredits
}}</span>
</td>
<td
class="align-middle"
:title="$t('subscription.creditsYouveAdded')"
>
{{ $t('subscription.creditsYouveAdded') }}
</td>
</tr>
</tbody>
</table>
<div
v-if="
isActiveSubscription &&
!showZeroState &&
permissions.canTopUp
"
class="flex flex-col gap-3"
>
<Button
v-if="isFreeTierPlan"
variant="gradient"
class="min-h-8 w-full rounded-lg p-2 text-sm font-normal"
@click="handleUpgradeToAddCredits"
>
{{ $t('subscription.upgradeToAddCredits') }}
</Button>
<Button
v-else
variant="secondary"
class="min-h-8 rounded-lg bg-interface-menu-component-surface-selected p-2 text-sm font-normal text-text-primary"
@click="handleAddApiCredits"
>
{{ $t('subscription.addCredits') }}
</Button>
</div>
</div>
</div>
</div>
<div v-if="isActiveSubscription" class="flex flex-col gap-2">
@@ -267,7 +360,8 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import Menu from 'primevue/menu'
import { computed, ref } from 'vue'
import Skeleton from 'primevue/skeleton'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useToast } from 'primevue/usetoast'
@@ -275,12 +369,14 @@ import { useToast } from 'primevue/usetoast'
import StatusBadge from '@/components/common/StatusBadge.vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import CreditsTile from '@/platform/cloud/subscription/components/CreditsTile.vue'
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useDialogService } from '@/services/dialogService'
import {
DEFAULT_TIER_KEY,
TIER_TO_KEY,
getTierCredits,
getTierPrice
} from '@/platform/cloud/subscription/constants/tierPricing'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
@@ -306,6 +402,8 @@ const {
subscription,
showSubscriptionDialog,
manageSubscription,
fetchStatus,
fetchBalance,
getMaxSeats,
resubscribe
} = useBillingContext()
@@ -376,6 +474,9 @@ function handleUpgrade() {
else showSubscriptionDialog()
}
function handleUpgradeToAddCredits() {
showPricingTable()
}
const subscriptionTier = computed(() => subscription.value?.tier ?? null)
const isYearlySubscription = computed(
() => subscription.value?.duration === 'ANNUAL'
@@ -443,6 +544,48 @@ const tierPrice = computed(() =>
const memberCount = computed(() => members.value.length)
const nextMonthInvoice = computed(() => memberCount.value * tierPrice.value)
const refillsDate = computed(() => {
if (!subscription.value?.renewalDate) return ''
const date = new Date(subscription.value.renewalDate)
const day = String(date.getDate()).padStart(2, '0')
const month = String(date.getMonth() + 1).padStart(2, '0')
const year = String(date.getFullYear()).slice(-2)
return `${month}/${day}/${year}`
})
const creditsRemainingLabel = computed(() =>
isYearlySubscription.value
? t(
'subscription.creditsRemainingThisYear',
{
date: refillsDate.value
},
{
escapeParameter: false
}
)
: t(
'subscription.creditsRemainingThisMonth',
{
date: refillsDate.value
},
{
escapeParameter: false
}
)
)
const planTotalCredits = computed(() => {
const credits = getTierCredits(tierKey.value)
if (credits === null) return '—'
const total = isYearlySubscription.value ? credits * 12 : credits
return n(total)
})
const includedCreditsDisplay = computed(
() => `${monthlyBonusCredits.value} / ${planTotalCredits.value}`
)
const tierBenefits = computed((): TierBenefit[] => {
const key = tierKey.value
const benefits: TierBenefit[] = []
@@ -459,6 +602,41 @@ const tierBenefits = computed((): TierBenefit[] => {
benefits.push(...getCommonTierBenefits(key, t, n))
return benefits
})
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
useSubscriptionCredits()
const { handleAddApiCredits, handleRefresh } = useSubscriptionActions()
// Focus-based polling: refresh balance when user returns from Stripe checkout
const PENDING_TOPUP_KEY = 'pending_topup_timestamp'
const TOPUP_EXPIRY_MS = 5 * 60 * 1000 // 5 minutes
function handleWindowFocus() {
const timestampStr = localStorage.getItem(PENDING_TOPUP_KEY)
if (!timestampStr) return
const timestamp = parseInt(timestampStr, 10)
// Clear expired tracking (older than 5 minutes)
if (Date.now() - timestamp > TOPUP_EXPIRY_MS) {
localStorage.removeItem(PENDING_TOPUP_KEY)
return
}
// Refresh and clear tracking to prevent repeated calls
void handleRefresh()
localStorage.removeItem(PENDING_TOPUP_KEY)
}
onMounted(() => {
window.addEventListener('focus', handleWindowFocus)
void Promise.all([fetchStatus(), fetchBalance()])
})
onBeforeUnmount(() => {
window.removeEventListener('focus', handleWindowFocus)
})
</script>
<style scoped>

View File

@@ -1,116 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import SubscriptionRequiredDialogContentUnified from './SubscriptionRequiredDialogContentUnified.vue'
const mockHandleSubscribeTeamClick = vi.fn()
const mockIsInPersonalWorkspace = ref(false)
vi.mock('@/platform/workspace/composables/useSubscriptionCheckout', () => ({
useSubscriptionCheckout: () => ({
checkoutStep: ref('pricing'),
isLoadingPreview: ref(false),
loadingTier: ref(null),
isSubscribing: ref(false),
isResubscribing: ref(false),
previewData: ref(null),
selectedTierKey: ref(null),
selectedTeamStop: ref(null),
selectedBillingCycle: ref('yearly'),
isPolling: ref(false),
isTeamCheckout: computed(() => false),
previewVariant: computed(() => null),
handleSubscribeClick: vi.fn(),
handleSubscribeTeamClick: mockHandleSubscribeTeamClick,
handleBackToPricing: vi.fn(),
handleSuccessClose: vi.fn(),
handleAddCreditCard: vi.fn(),
handleConfirmTransition: vi.fn(),
handleTeamSubscribe: vi.fn(),
handleResubscribe: vi.fn()
})
}))
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
get isInPersonalWorkspace() {
return mockIsInPersonalWorkspace.value
}
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { back: 'Back', close: 'Close' },
subscription: { descriptionWorkspace: 'Choose your plan' }
}
}
})
const TEAM_PAYLOAD = {
stop: { id: 'stop_1', usd: 700, credits: 70000, discountedUsd: 560 },
billingCycle: 'yearly'
}
const UnifiedPricingTableStub = {
name: 'UnifiedPricingTable',
emits: ['subscribeTeam'],
template: `<div>
<button data-testid="subscribe-team-btn" @click="$emit('subscribeTeam', payload)">Team</button>
</div>`,
setup() {
return { payload: TEAM_PAYLOAD }
}
}
function renderComponent(props: Record<string, unknown> = {}) {
return render(SubscriptionRequiredDialogContentUnified, {
props: { onClose: vi.fn(), ...props },
global: {
plugins: [i18n],
stubs: {
UnifiedPricingTable: UnifiedPricingTableStub,
SubscriptionAddPaymentPreviewWorkspace: { template: '<div />' },
SubscriptionTransitionPreviewWorkspace: { template: '<div />' },
SubscriptionSuccessWorkspace: { template: '<div />' }
}
}
})
}
describe('SubscriptionRequiredDialogContentUnified team-plan subscribe', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsInPersonalWorkspace.value = false
})
it('advances to team checkout from a team workspace', async () => {
const user = userEvent.setup()
mockIsInPersonalWorkspace.value = false
renderComponent()
await user.click(screen.getByTestId('subscribe-team-btn'))
await vi.waitFor(() => {
expect(mockHandleSubscribeTeamClick).toHaveBeenCalledWith(TEAM_PAYLOAD)
})
})
it('advances to team checkout from a personal workspace (no reroute)', async () => {
const user = userEvent.setup()
mockIsInPersonalWorkspace.value = true
renderComponent()
await user.click(screen.getByTestId('subscribe-team-btn'))
await vi.waitFor(() => {
expect(mockHandleSubscribeTeamClick).toHaveBeenCalledWith(TEAM_PAYLOAD)
})
})
})

View File

@@ -44,11 +44,9 @@
</p>
</div>
<!-- Pricing Table Step. v-show (not v-if) keeps it mounted so the plan,
billing cycle, and credit-stop selection survive a round trip to the
confirm step and back. -->
<!-- Pricing Table Step (unified: personal/team plan toggle) -->
<UnifiedPricingTable
v-show="checkoutStep === 'pricing'"
v-if="checkoutStep === 'pricing'"
class="xl:flex-1"
:initial-plan-mode="initialPlanMode"
:is-loading="isLoadingPreview || isResubscribing"
@@ -58,51 +56,48 @@
@subscribe-team="handleSubscribeTeamClick"
/>
<template v-if="checkoutStep === 'preview'">
<SubscriptionTransitionPreviewWorkspace
v-if="previewVariant === 'team-change'"
:preview-data="previewData!"
:team-plan="selectedTeamStop!"
:is-loading="isSubscribing || isPolling"
@confirm="handleTeamSubscribe"
@back="handleBackToPricing"
/>
<!-- Subscription Preview Step - New Subscription -->
<SubscriptionAddPaymentPreviewWorkspace
v-else-if="
checkoutStep === 'preview' &&
previewData &&
previewData.transition_type === 'new_subscription'
"
:preview-data="previewData"
:tier-key="selectedTierKey!"
:billing-cycle="selectedBillingCycle"
:is-loading="isSubscribing || isPolling"
@add-credit-card="handleAddCreditCard"
@back="handleBackToPricing"
/>
<SubscriptionAddPaymentPreviewWorkspace
v-else-if="previewVariant === 'team-new'"
:team-plan="selectedTeamStop!"
:billing-cycle="selectedBillingCycle"
:is-loading="isSubscribing || isPolling"
@add-credit-card="handleTeamSubscribe"
@back="handleBackToPricing"
/>
<!-- Subscription Preview Step - Plan Transition -->
<SubscriptionTransitionPreviewWorkspace
v-else-if="
checkoutStep === 'preview' &&
previewData &&
previewData.transition_type !== 'new_subscription'
"
:preview-data="previewData"
:is-loading="isSubscribing || isPolling"
@confirm="handleConfirmTransition"
@back="handleBackToPricing"
/>
<SubscriptionAddPaymentPreviewWorkspace
v-else-if="previewVariant === 'personal-new'"
:preview-data="previewData"
:tier-key="selectedTierKey!"
:billing-cycle="selectedBillingCycle"
:is-loading="isSubscribing || isPolling"
@add-credit-card="handleAddCreditCard"
@back="handleBackToPricing"
/>
<SubscriptionTransitionPreviewWorkspace
v-else-if="previewVariant === 'personal-change'"
:preview-data="previewData!"
:is-loading="isSubscribing || isPolling"
@confirm="handleConfirmTransition"
@back="handleBackToPricing"
/>
</template>
<!-- Subscription Preview Step - Team (display-only until the BE slider
contract lands; the confirm CTA is stubbed below) -->
<SubscriptionAddPaymentPreviewWorkspace
v-else-if="checkoutStep === 'preview' && selectedTeamStop"
:team-plan="selectedTeamStop"
@add-credit-card="handleTeamSubscribe"
@back="handleBackToPricing"
/>
<!-- Success Step - "You're all set" -->
<SubscriptionSuccessWorkspace
v-if="checkoutStep === 'success' && (selectedTierKey || isTeamCheckout)"
v-else-if="checkoutStep === 'success' && selectedTierKey"
:tier-key="selectedTierKey"
:team-plan="selectedTeamStop"
:preview-data="previewData"
:is-team="isTeamCheckout"
@close="handleSuccessClose"
/>
</div>
@@ -110,7 +105,8 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { useEventListener } from '@vueuse/core'
import { useToast } from 'primevue/usetoast'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
@@ -131,6 +127,9 @@ const emit = defineEmits<{
close: [subscribed: boolean]
}>()
const { t } = useI18n()
const toast = useToast()
const {
checkoutStep,
isLoadingPreview,
@@ -142,32 +141,26 @@ const {
selectedTeamStop,
selectedBillingCycle,
isPolling,
isTeamCheckout,
previewVariant,
handleSubscribeClick,
handleSubscribeTeamClick,
handleBackToPricing,
handleSuccessClose,
handleAddCreditCard,
handleConfirmTransition,
handleTeamSubscribe,
handleResubscribe
} = useSubscriptionCheckout(emit)
// Backspace mirrors the back arrow on the confirm step, but never while an
// editable element is focused (let it delete text there).
useEventListener(window, 'keydown', (event: KeyboardEvent) => {
if (event.key !== 'Backspace' || checkoutStep.value !== 'preview') return
const target = event.target
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
(target instanceof HTMLElement && target.isContentEditable)
) {
return
}
event.preventDefault()
handleBackToPricing()
})
// Personal-tier checkout reuses the full useSubscriptionCheckout flow above.
// Team-plan checkout renders the confirm step from the selected slider stop,
// but the final subscribe is blocked on the BE discount-breakpoint contract
// (FE-934 / doc Open Q#2: the slider stop -> plan-slug / subscribe-request shape
// is undefined), so the confirm CTA is stubbed until that lands.
function handleTeamSubscribe() {
toast.add({
severity: 'info',
summary: t('subscription.teamPlan.name'),
detail: t('subscription.teamPlan.checkoutComingSoon'),
life: 4000
})
}
</script>

View File

@@ -46,8 +46,7 @@ const i18n = createI18n({
g: { back: 'Back', close: 'Close' },
subscription: {
plansForWorkspace: 'Plans for {workspace}',
teamWorkspace: 'Team Workspace',
personalWorkspace: 'Personal Workspace'
teamWorkspace: 'Team'
},
credits: {
topUp: {
@@ -89,19 +88,12 @@ const SuccessStub = {
}
function renderComponent(
props: {
onClose?: () => void
reason?: SubscriptionDialogReason
isPersonal?: boolean
} = {}
props: { onClose?: () => void; reason?: SubscriptionDialogReason } = {}
) {
return render(SubscriptionRequiredDialogContentWorkspace, {
props: {
onClose: props.onClose ?? vi.fn(),
...(props.reason ? { reason: props.reason } : {}),
...(props.isPersonal !== undefined
? { isPersonal: props.isPersonal }
: {})
...(props.reason ? { reason: props.reason } : {})
},
global: {
plugins: [
@@ -132,18 +124,6 @@ describe('SubscriptionRequiredDialogContentWorkspace', () => {
expect(screen.queryByTestId('transition-preview')).not.toBeInTheDocument()
})
it('shows the team workspace header by default', () => {
renderComponent()
expect(screen.getByText('Team Workspace')).toBeInTheDocument()
expect(screen.queryByText('Personal Workspace')).not.toBeInTheDocument()
})
it('shows the personal workspace header for a single-seat context', () => {
renderComponent({ isPersonal: true })
expect(screen.getByText('Personal Workspace')).toBeInTheDocument()
expect(screen.queryByText('Team Workspace')).not.toBeInTheDocument()
})
it('shows close button and hides back button on pricing step', () => {
renderComponent()
expect(screen.getByLabelText('Close')).toBeInTheDocument()

View File

@@ -24,17 +24,12 @@
</Button>
<div class="flex flex-col items-center gap-3">
<!-- Decorative workspace-initial icon; not user-facing text -->
<!-- Decorative initial for "Team" workspace icon; not user-facing text -->
<div
:class="
cn(
'flex size-10 items-center justify-center rounded-xl text-lg font-semibold text-white',
isPersonal ? 'bg-muted-foreground/30' : 'bg-primary-background'
)
"
class="flex size-10 items-center justify-center rounded-xl bg-primary-background text-lg font-semibold text-white"
aria-hidden="true"
>
{{ isPersonal ? 'P' : 'T' }}
T
</div>
<i18n-t
keypath="subscription.plansForWorkspace"
@@ -42,14 +37,8 @@
class="m-0 font-inter text-2xl font-semibold text-base-foreground"
>
<template #workspace>
<span
:class="isPersonal ? 'text-muted-foreground' : 'text-emerald-400'"
>
{{
isPersonal
? $t('subscription.personalWorkspace')
: $t('subscription.teamWorkspace')
}}
<span class="text-emerald-400">
{{ $t('subscription.teamWorkspace') }}
</span>
</template>
</i18n-t>
@@ -113,8 +102,6 @@
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import Button from '@/components/ui/button/Button.vue'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { useSubscriptionCheckout } from '@/platform/workspace/composables/useSubscriptionCheckout'
@@ -124,14 +111,9 @@ import SubscriptionAddPaymentPreviewWorkspace from './SubscriptionAddPaymentPrev
import SubscriptionSuccessWorkspace from './SubscriptionSuccessWorkspace.vue'
import SubscriptionTransitionPreviewWorkspace from './SubscriptionTransitionPreviewWorkspace.vue'
const {
onClose,
reason,
isPersonal = false
} = defineProps<{
const { onClose, reason } = defineProps<{
onClose: () => void
reason?: SubscriptionDialogReason
isPersonal?: boolean
}>()
const emit = defineEmits<{

View File

@@ -1,9 +1,8 @@
import userEvent from '@testing-library/user-event'
import { cleanup, render, screen } from '@testing-library/vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
import { MAX_WORKSPACE_MEMBERS } from '@/platform/workspace/stores/teamWorkspaceStore'
import SubscriptionSuccessWorkspace from './SubscriptionSuccessWorkspace.vue'
@@ -14,43 +13,7 @@ vi.mock('vue-i18n', () => ({
})
}))
const { mockMembers, mockPendingInvites } = vi.hoisted(() => ({
mockMembers: [] as unknown[],
mockPendingInvites: [] as unknown[]
}))
// Provide just the seat cap + member/invite slots so the component import doesn't
// drag the team store's i18n/app chain into this unit test.
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
MAX_WORKSPACE_MEMBERS: 30,
useTeamWorkspaceStore: () => ({
members: mockMembers,
pendingInvites: mockPendingInvites
})
}))
const { mockFlags } = vi.hoisted(() => ({
mockFlags: { teamWorkspacesEnabled: true }
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({ flags: mockFlags })
}))
vi.mock('./InviteMembersForm.vue', () => ({
default: {
name: 'InviteMembersForm',
props: ['maxSeats', 'source', 'submitLabel', 'placeholder'],
emits: ['submitted'],
template:
'<div data-testid="invite-form">seats:{{ maxSeats }}<button data-testid="stub-submit" @click="$emit(\'submitted\', [\'a@b.com\'])">submit</button></div>'
}
}))
function makePreviewData(
priceCents: number,
duration: 'MONTHLY' | 'ANNUAL' = 'MONTHLY'
): PreviewSubscribeResponse {
function makePreviewData(priceCents: number): PreviewSubscribeResponse {
return {
allowed: true,
transition_type: 'new_subscription',
@@ -63,7 +26,7 @@ function makePreviewData(
new_plan: {
slug: 'standard-monthly',
tier: 'STANDARD',
duration,
duration: 'MONTHLY',
price_cents: priceCents,
credits_cents: 0,
seat_summary: {
@@ -75,21 +38,11 @@ function makePreviewData(
}
}
const TEAM_STOP = {
id: 'team_700',
usd: 700,
credits: 147_700,
discountedUsd: 630
}
function renderCard(props: Record<string, unknown> = {}) {
function renderCard() {
return render(SubscriptionSuccessWorkspace, {
props: {
tierKey: 'creator',
previewData: {
new_plan: { price_cents: 1600 }
} as unknown as PreviewSubscribeResponse,
...props
tierKey: 'standard',
previewData: makePreviewData(1600)
},
global: {
mocks: { $t: (key: string) => key },
@@ -102,115 +55,16 @@ function renderCard(props: Record<string, unknown> = {}) {
})
}
function renderTeamCard(props: Record<string, unknown> = {}) {
return renderCard({
tierKey: null,
teamPlan: TEAM_STOP,
isTeam: true,
...props
})
}
describe('SubscriptionSuccessWorkspace', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFlags.teamWorkspacesEnabled = true
mockMembers.length = 0
mockPendingInvites.length = 0
})
afterEach(() => {
cleanup()
})
it('renders the all-set heading and plan price', () => {
renderCard()
expect(screen.getByText('subscription.success.allSet')).toBeTruthy()
expect(screen.getByText('$16')).toBeTruthy()
})
it('renders the team plan summary from the selected stop', () => {
renderTeamCard()
expect(screen.getByText('subscription.teamPlan.name')).toBeTruthy()
expect(screen.getByText('$630')).toBeTruthy()
expect(screen.getByText(/147700/)).toBeTruthy()
})
it('shows the monthly-equivalent price for an annual personal plan', () => {
render(SubscriptionSuccessWorkspace, {
props: {
tierKey: 'creator',
previewData: makePreviewData(33_600, 'ANNUAL')
},
global: {
mocks: { $t: (key: string) => key },
stubs: {
Button: {
template: '<button @click="$emit(\'click\')"><slot /></button>'
}
}
}
})
expect(screen.getByText('$28')).toBeTruthy()
expect(screen.queryByText('$336')).toBeNull()
})
it('emits close when the close button is clicked', async () => {
const { emitted } = renderCard({ isTeam: false })
const { emitted } = renderCard()
await userEvent.click(screen.getByRole('button'))
expect(emitted().close).toBeTruthy()
})
it('renders the invite block capped at the workspace member limit', () => {
renderTeamCard()
expect(screen.getByText('subscription.success.inviteTitle')).toBeTruthy()
// The buyer holds one of the flat team-member seats, so the rest are invitable.
expect(screen.getByTestId('invite-form')).toHaveTextContent(
`seats:${MAX_WORKSPACE_MEMBERS - 1}`
)
})
it('places the Send invites action in the footer for a team upgrade', () => {
renderTeamCard()
expect(screen.getByText('subscription.success.sendInvites')).toBeTruthy()
})
it('shows no Send invites action for a personal upgrade', () => {
renderCard({ isTeam: false })
expect(screen.queryByText('subscription.success.sendInvites')).toBeNull()
})
it('does not render the invite block for a personal upgrade', () => {
renderCard({ isTeam: false })
expect(screen.queryByText('subscription.success.inviteTitle')).toBeNull()
expect(screen.queryByTestId('invite-form')).toBeNull()
})
it('hides the invite block when team workspaces are disabled', () => {
mockFlags.teamWorkspacesEnabled = false
renderTeamCard()
expect(screen.queryByTestId('invite-form')).toBeNull()
})
it('subtracts existing members and pending invites from invitable seats', () => {
mockMembers.push({}, {})
mockPendingInvites.push({})
renderTeamCard()
expect(screen.getByTestId('invite-form')).toHaveTextContent(
`seats:${MAX_WORKSPACE_MEMBERS - 3}`
)
})
it('swaps the form for the success message once invites are submitted', async () => {
renderTeamCard()
expect(screen.getByTestId('invite-form')).toBeTruthy()
await userEvent.click(screen.getByTestId('stub-submit'))
expect(screen.queryByTestId('invite-form')).toBeNull()
expect(
screen.getByText('workspacePanel.inviteMemberDialog.invitedMessage')
).toBeTruthy()
expect(screen.queryByText('subscription.success.sendInvites')).toBeNull()
})
})

View File

@@ -3,7 +3,7 @@
class="mx-auto flex h-full max-w-[400px] flex-col items-stretch justify-between text-sm"
>
<div class="flex flex-col items-center gap-4 pt-8">
<i class="pi pi-check-circle text-5xl text-success-background" />
<i class="pi pi-check-circle text-success-foreground text-5xl" />
<h2
class="m-0 text-center text-xl font-semibold text-base-foreground lg:text-2xl"
>
@@ -33,54 +33,10 @@
</div>
</div>
<div v-if="showInviteBlock" class="mt-4 flex w-full flex-col gap-2">
<h3 class="m-0 text-base font-semibold text-base-foreground">
{{ $t('subscription.success.inviteTitle') }}
</h3>
<p class="m-0 text-sm text-muted-foreground">
{{ $t('subscription.success.inviteSubtext') }}
</p>
<div aria-live="polite">
<p
v-if="invitedEmails.length > 0"
ref="invitedMessage"
tabindex="-1"
class="text-success-foreground m-0 text-sm"
>
{{
$t(
'workspacePanel.inviteMemberDialog.invitedMessage',
{ emails: invitedEmails.join(', ') },
invitedEmails.length
)
}}
</p>
<InviteMembersForm
v-else
ref="inviteForm"
:show-submit="false"
source="post_upgrade_success"
:submit-label="$t('subscription.success.sendInvites')"
:placeholder="$t('subscription.success.inviteEmailsPlaceholder')"
:max-seats="invitableSeats"
@submitted="onInvited"
/>
</div>
</div>
<!-- Team success "Invite your team" block renders here (FE-965 / DES-394). -->
</div>
<div class="flex flex-col gap-2 pt-8">
<Button
v-if="showInviteBlock && invitedEmails.length === 0"
variant="tertiary"
size="lg"
class="w-full rounded-lg"
:disabled="!canSendInvites"
:loading="isSendingInvites"
@click="handleSendInvites"
>
{{ $t('subscription.success.sendInvites') }}
</Button>
<Button
variant="secondary"
size="lg"
@@ -94,33 +50,17 @@
</template>
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import Button from '@/components/ui/button/Button.vue'
import type { TeamPlanSelection } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import { isAnnualDuration } from '@/platform/cloud/subscription/utils/planDuration'
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
import {
MAX_WORKSPACE_MEMBERS,
useTeamWorkspaceStore
} from '@/platform/workspace/stores/teamWorkspaceStore'
import InviteMembersForm from './InviteMembersForm.vue'
const {
tierKey,
previewData = null,
teamPlan = null,
isTeam = false
} = defineProps<{
tierKey?: Exclude<TierKey, 'free' | 'founder'> | null
const { tierKey, previewData = null } = defineProps<{
tierKey: Exclude<TierKey, 'free' | 'founder'>
previewData?: PreviewSubscribeResponse | null
teamPlan?: TeamPlanSelection | null
isTeam?: boolean
}>()
defineEmits<{
@@ -128,55 +68,14 @@ defineEmits<{
}>()
const { t, n } = useI18n()
const { flags } = useFeatureFlags()
const workspaceStore = useTeamWorkspaceStore()
const tierName = computed(() =>
teamPlan
? t('subscription.teamPlan.name')
: t(`subscription.tiers.${tierKey}.name`)
const tierName = computed(() => t(`subscription.tiers.${tierKey}.name`))
const displayPrice = computed(() =>
previewData?.new_plan
? (previewData.new_plan.price_cents / 100).toFixed(0)
: '0'
)
const displayPrice = computed(() => {
if (teamPlan) return String(teamPlan.discountedUsd)
if (!previewData?.new_plan) return '0'
const cents = previewData.new_plan.price_cents
const monthlyCents = isAnnualDuration(previewData.new_plan.duration)
? cents / 12
: cents
return (monthlyCents / 100).toFixed(0)
})
const displayCredits = computed(() =>
n(teamPlan ? teamPlan.credits : tierKey ? (getTierCredits(tierKey) ?? 0) : 0)
)
const occupiedSeats = computed(() =>
Math.max(
1,
workspaceStore.members.length + workspaceStore.pendingInvites.length
)
)
const invitableSeats = computed(() =>
Math.max(0, MAX_WORKSPACE_MEMBERS - occupiedSeats.value)
)
const showInviteBlock = computed(() => isTeam && flags.teamWorkspacesEnabled)
const invitedEmails = ref<string[]>([])
const invitedMessage = ref<HTMLElement>()
const inviteForm = ref<InstanceType<typeof InviteMembersForm>>()
const canSendInvites = computed(() => inviteForm.value?.canSubmit ?? false)
const isSendingInvites = computed(() => inviteForm.value?.loading ?? false)
function handleSendInvites() {
void inviteForm.value?.submit()?.catch(console.error)
}
async function onInvited(emails: string[]) {
invitedEmails.value = emails
await nextTick()
invitedMessage.value?.focus()
}
const displayCredits = computed(() => n(getTierCredits(tierKey) ?? 0))
</script>

View File

@@ -1,228 +0,0 @@
import { render, screen } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import type {
PreviewSubscribeResponse,
SubscriptionDuration,
SubscriptionTier
} from '@/platform/workspace/api/workspaceApi'
import SubscriptionTransitionPreviewWorkspace from './SubscriptionTransitionPreviewWorkspace.vue'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
n: (value: number) => value.toLocaleString('en-US')
})
}))
const globalOptions = {
mocks: { $t: (key: string) => key },
stubs: {
SubscriptionTermsNote: { template: '<div />' },
Button: { template: '<button @click="$emit(\'click\')"><slot /></button>' }
}
}
function plan(
tier: SubscriptionTier,
duration: SubscriptionDuration,
priceCents: number
) {
return {
slug: `${tier.toLowerCase()}-${duration.toLowerCase()}`,
tier,
duration,
price_cents: priceCents,
credits_cents: 0,
seat_summary: {
seat_count: 1,
total_cost_cents: priceCents,
total_credits_cents: 0
},
period_end: '2027-06-28T00:00:00Z'
}
}
function preview(
overrides: Partial<PreviewSubscribeResponse>
): PreviewSubscribeResponse {
return {
allowed: true,
transition_type: 'upgrade',
effective_at: '2026-06-19T00:00:00Z',
is_immediate: true,
cost_today_cents: 0,
cost_next_period_cents: 0,
credits_today_cents: 0,
credits_next_period_cents: 0,
new_plan: plan('CREATOR', 'MONTHLY', 3500),
...overrides
}
}
describe('SubscriptionTransitionPreviewWorkspace', () => {
it('renders an immediate yearly upgrade with proration and upfront credits', () => {
render(SubscriptionTransitionPreviewWorkspace, {
props: {
previewData: preview({
transition_type: 'duration_change',
is_immediate: true,
cost_today_cents: 31_850,
current_plan: plan('CREATOR', 'MONTHLY', 3500),
new_plan: plan('CREATOR', 'ANNUAL', 33_600)
})
},
global: globalOptions
})
expect(
screen.getByText('subscription.preview.confirmUpgradeTitle')
).toBeTruthy()
expect(screen.getByText('$28')).toBeTruthy()
expect(screen.getByText('subscription.billedYearly')).toBeTruthy()
expect(screen.getByText('subscription.preview.switchesToday')).toBeTruthy()
expect(
screen.getByText('subscription.preview.yearlySubscription')
).toBeTruthy()
expect(screen.getByText('$336.00')).toBeTruthy()
expect(screen.getByText(' $17.50')).toBeTruthy()
expect(
screen.getByText('subscription.preview.creditsYoullGetToday')
).toBeTruthy()
expect(screen.getByText('88,800')).toBeTruthy()
expect(screen.getByText('$318.50')).toBeTruthy()
expect(
screen.getByText('subscription.preview.confirmUpgradeCta')
).toBeTruthy()
expect(screen.queryByText('subscription.preview.startsOn')).toBeNull()
})
it('renders an immediate monthly tier upgrade with monthly refill', () => {
render(SubscriptionTransitionPreviewWorkspace, {
props: {
previewData: preview({
transition_type: 'upgrade',
is_immediate: true,
cost_today_cents: 8250,
current_plan: plan('CREATOR', 'MONTHLY', 3500),
new_plan: plan('PRO', 'MONTHLY', 10_000)
})
},
global: globalOptions
})
expect(screen.getByText('$100')).toBeTruthy()
expect(screen.getByText('subscription.billedMonthly')).toBeTruthy()
expect(
screen.getByText('subscription.preview.newMonthlySubscription')
).toBeTruthy()
expect(
screen.getByText('subscription.preview.eachMonthCreditsRefill')
).toBeTruthy()
expect(screen.getByText('21,100')).toBeTruthy()
expect(screen.getByText('$82.50')).toBeTruthy()
})
it('renders a scheduled downgrade with the after-that block and no charge', () => {
render(SubscriptionTransitionPreviewWorkspace, {
props: {
previewData: preview({
transition_type: 'downgrade',
is_immediate: false,
cost_today_cents: 0,
effective_at: '2027-06-28T00:00:00Z',
current_plan: plan('PRO', 'MONTHLY', 10_000),
new_plan: plan('CREATOR', 'MONTHLY', 3500)
})
},
global: globalOptions
})
expect(
screen.getAllByText('subscription.preview.confirmChange').length
).toBeGreaterThan(0)
expect(screen.getByText('$35')).toBeTruthy()
expect(screen.getByText('subscription.preview.startsOn')).toBeTruthy()
expect(screen.getByText('$0.00')).toBeTruthy()
expect(screen.getByText('subscription.preview.afterThat')).toBeTruthy()
expect(
screen.getByText('subscription.preview.creditsRefillMonthlyTo')
).toBeTruthy()
expect(screen.getByText('7,400')).toBeTruthy()
expect(screen.getByText('subscription.preview.stayOnUntil')).toBeTruthy()
expect(screen.queryByText('subscription.preview.switchesToday')).toBeNull()
expect(
screen.queryByText('subscription.preview.yearlySubscription')
).toBeNull()
})
it('renders a team credit-commit change using the slider stop for name and credits', () => {
render(SubscriptionTransitionPreviewWorkspace, {
props: {
previewData: preview({
transition_type: 'upgrade',
is_immediate: true,
cost_today_cents: 105_000,
current_plan: plan('PRO', 'MONTHLY', 70_000),
new_plan: plan('PRO', 'MONTHLY', 140_000)
}),
teamPlan: {
id: 'team_1400',
usd: 1400,
credits: 295_400,
discountedUsd: 1295
}
},
global: globalOptions
})
// Plan name and refill credits come from the team stop, not the personal
// tier table (which would yield 0 credits for a team plan).
expect(screen.getByText('subscription.teamPlan.name')).toBeTruthy()
expect(screen.getByText('295,400')).toBeTruthy()
// Proration money stays driven by previewData.
expect(
screen.getByText('subscription.preview.newMonthlySubscription')
).toBeTruthy()
expect(screen.getByText('$1,400.00')).toBeTruthy()
expect(screen.getByText(' $350.00')).toBeTruthy()
expect(screen.getByText('$1,050.00')).toBeTruthy()
expect(
screen.getByText('subscription.preview.confirmUpgradeCta')
).toBeTruthy()
})
it('renders a yearly team credit-commit change with annual refill and yearly labels', () => {
render(SubscriptionTransitionPreviewWorkspace, {
props: {
previewData: preview({
transition_type: 'upgrade',
is_immediate: true,
cost_today_cents: 1_260_000,
current_plan: plan('PRO', 'MONTHLY', 70_000),
new_plan: plan('PRO', 'ANNUAL', 1_680_000)
}),
teamPlan: {
id: 'team_1400',
usd: 1400,
credits: 295_400,
discountedUsd: 1295
}
},
global: globalOptions
})
// Yearly grants 12 months of the stop's monthly credits up front.
expect(screen.getByText('subscription.teamPlan.name')).toBeTruthy()
expect(screen.getByText('3,544,800')).toBeTruthy()
expect(
screen.getByText('subscription.preview.creditsYoullGetToday')
).toBeTruthy()
expect(
screen.getByText('subscription.preview.refillReplacesNote')
).toBeTruthy()
// Yearly line label; proration money stays driven by previewData.
expect(
screen.getByText('subscription.preview.yearlySubscription')
).toBeTruthy()
expect(screen.getByText('$16,800.00')).toBeTruthy()
expect(screen.getByText(' $4,200.00')).toBeTruthy()
expect(screen.getByText('$12,600.00')).toBeTruthy()
})
})

View File

@@ -1,120 +1,144 @@
<template>
<h2 class="m-0 mb-8 text-center text-xl text-muted-foreground lg:text-2xl">
{{ confirmTitle }}
{{ $t('subscription.preview.confirmPlanChange') }}
</h2>
<div
class="mx-auto flex h-full max-w-[400px] flex-col items-stretch justify-between text-sm"
class="mx-auto flex h-full flex-col items-stretch justify-between text-sm"
>
<div>
<!-- Plan Header -->
<div class="flex flex-col gap-2">
<span class="text-sm font-semibold text-base-foreground">
{{ newTierName }}
</span>
<div class="flex items-baseline gap-2">
<span class="text-4xl font-semibold text-base-foreground">
${{ heroPrice }}
<!-- Plan Comparison Header -->
<div class="flex items-center gap-4">
<!-- Current Plan -->
<div class="flex flex-1 flex-col gap-1">
<span class="text-sm text-base-foreground">
{{ currentTierName }}
</span>
<span class="text-xl text-base-foreground">
{{ $t('subscription.usdPerMonth') }}
</span>
</div>
<template v-if="isImmediate">
<span class="text-muted-foreground">
<div class="flex items-baseline gap-1">
<span class="text-2xl font-semibold text-base-foreground">
${{ currentDisplayPrice }}
</span>
<span class="text-sm text-base-foreground">
{{ $t('subscription.usdPerMonth') }}
</span>
</div>
<div class="flex items-center gap-1 text-sm text-muted-foreground">
<i class="icon-[comfy--credits] size-3.5 shrink-0 bg-amber-400" />
<span
>{{ currentDisplayCredits }}
{{ $t('subscription.perMonth') }}</span
>
</div>
<span class="inline text-sm text-muted-foreground">
{{
newIsYearly
? $t('subscription.billedYearly', {
total: annualTotalFormatted
})
: $t('subscription.billedMonthly')
$t('subscription.preview.ends', { date: currentPeriodEndDate })
}}
</span>
<span class="text-muted-foreground">
{{ $t('subscription.preview.switchesToday') }}
</span>
</template>
<span v-else class="text-muted-foreground">
{{
$t('subscription.preview.startsOn', { date: effectiveDateLabel })
}}
</span>
</div>
<!-- Proration Line Items (immediate changes) -->
<div v-if="isImmediate" class="flex flex-col gap-2 pt-10">
<div class="flex items-center justify-between text-muted-foreground">
<span>{{ subscriptionLineLabel }}</span>
<span>{{ money(newPlanPriceUsd) }}</span>
</div>
<div
v-if="prorationCreditUsd > 0"
class="flex items-center justify-between text-muted-foreground"
>
<span>
{{
$t('subscription.preview.creditFromCurrent', {
plan: creditFromPlanLabel
})
}}
</span>
<span> {{ money(prorationCreditUsd) }}</span>
</div>
</div>
<!-- Credits Refill (immediate changes) -->
<div v-if="isImmediate" class="flex flex-col gap-2 pt-10">
<div class="flex items-center justify-between">
<span class="text-base-foreground">{{ refillLabel }}</span>
<div class="flex items-center gap-1">
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
<span class="font-bold text-base-foreground">{{
refillCredits
}}</span>
<!-- Arrow -->
<i class="pi pi-arrow-right size-8 shrink-0 text-muted-foreground" />
<!-- New Plan -->
<div class="flex flex-1 flex-col gap-1">
<span class="text-sm font-semibold text-base-foreground">
{{ newTierName }}
</span>
<div class="flex items-baseline gap-1">
<span class="text-2xl font-semibold text-base-foreground">
${{ newDisplayPrice }}
</span>
<span class="text-sm text-base-foreground">
{{ $t('subscription.usdPerMonth') }}
</span>
</div>
<div class="flex items-center gap-1 text-sm text-muted-foreground">
<i class="icon-[comfy--credits] size-3.5 shrink-0 bg-amber-400" />
<span
>{{ newDisplayCredits }} {{ $t('subscription.perMonth') }}</span
>
</div>
<span class="text-sm text-muted-foreground">
{{ $t('subscription.preview.starting', { date: effectiveDate }) }}
</span>
</div>
<span v-if="newIsYearly" class="text-sm text-muted-foreground">
{{ $t('subscription.preview.refillReplacesNote') }}
</span>
</div>
<!-- After-That Block (scheduled changes) -->
<div v-else class="flex flex-col gap-2 pt-10">
<span
class="text-xs font-semibold tracking-wide text-muted-foreground uppercase"
>
{{ $t('subscription.preview.afterThat') }}
</span>
<div class="flex items-center justify-between">
<span class="text-base-foreground">
{{ $t('subscription.preview.creditsRefillMonthlyTo') }}
</span>
<div class="flex items-center gap-1">
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
<span class="font-bold text-base-foreground">{{
monthlyRefillCredits
}}</span>
</div>
</div>
<span class="text-sm text-muted-foreground">
<!-- Next Cycle Section -->
<div class="flex flex-col gap-3 pt-12 pb-6">
<span class="text-base-foreground">
{{
$t('subscription.preview.billedEachMonth', {
amount: moneyShort(newMonthlyChargeUsd)
$t('subscription.preview.everyMonthStarting', {
date: effectiveDate
})
}}
</span>
<div class="flex items-center justify-between">
<span class="text-muted-foreground">
{{ $t('subscription.preview.creditsRefillTo') }}
</span>
<div class="flex items-center gap-1">
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
<span class="font-bold text-base-foreground">
{{ newDisplayCredits }}
</span>
</div>
</div>
<div class="flex items-center justify-between">
<span class="text-muted-foreground">
{{ $t('subscription.preview.youllBeCharged') }}
</span>
<span class="text-base-foreground">${{ newMonthlyCharge }}</span>
</div>
</div>
<!-- Total Due -->
<div class="mt-10 flex flex-col gap-2 border-t border-border-subtle pt-8">
<!-- Proration Section -->
<div
v-if="showProration"
class="flex flex-col gap-2 border-t border-border-subtle py-6"
>
<div
v-if="proratedRefundCents > 0"
class="flex items-center justify-between"
>
<span class="text-muted-foreground">
{{
$t('subscription.preview.proratedRefund', {
plan: currentTierName
})
}}
</span>
<span class="text-muted-foreground">-${{ proratedRefund }}</span>
</div>
<div
v-if="proratedChargeCents > 0"
class="flex items-center justify-between"
>
<span class="text-muted-foreground">
{{
$t('subscription.preview.proratedCharge', { plan: newTierName })
}}
</span>
<span class="text-muted-foreground">${{ proratedCharge }}</span>
</div>
</div>
<!-- Total Due Section -->
<div class="flex flex-col gap-2 border-t border-border-subtle pt-6">
<div class="flex items-center justify-between text-base">
<span class="text-base-foreground">
{{ $t('subscription.preview.totalDueToday') }}
</span>
<span class="font-bold text-base-foreground">
{{ money(totalDueTodayUsd) }}
${{ totalDueToday }}
</span>
</div>
<span class="text-sm text-muted-foreground">{{ totalNote }}</span>
<span class="text-sm text-muted-foreground">
{{
$t('subscription.preview.nextPaymentDue', {
date: nextPaymentDate
})
}}
</span>
</div>
</div>
@@ -123,13 +147,13 @@
<SubscriptionTermsNote />
<Button
variant="tertiary"
variant="secondary"
size="lg"
class="w-full rounded-lg"
:loading="isLoading"
@click="$emit('confirm')"
>
{{ confirmCta }}
{{ $t('subscription.preview.switchToPlan', { plan: newTierName }) }}
</Button>
<Button
@@ -148,26 +172,17 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { TeamPlanSelection } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing'
import { isAnnualDuration } from '@/platform/cloud/subscription/utils/planDuration'
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
import SubscriptionTermsNote from './SubscriptionTermsNote.vue'
type PersonalTierKey = 'standard' | 'creator' | 'pro'
const {
previewData,
isLoading = false,
teamPlan = null
} = defineProps<{
interface Props {
previewData: PreviewSubscribeResponse
isLoading?: boolean
/** Set for a team credit-commit change: plan name + refill credits come from
* the selected slider stop; all proration money stays driven by previewData. */
teamPlan?: TeamPlanSelection | null
}>()
}
const { previewData, isLoading = false } = defineProps<Props>()
defineEmits<{
confirm: []
@@ -181,129 +196,90 @@ function formatTierName(tier: string): string {
}
function formatDate(dateStr: string): string {
return new Intl.DateTimeFormat('en-US', {
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC'
}).format(new Date(dateStr))
year: 'numeric'
})
}
function money(usd: number): string {
return `$${usd.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}`
}
function moneyShort(usd: number): string {
return `$${n(usd)}`
}
function tierMonthlyCredits(tier: string): number {
return getTierCredits(tier.toLowerCase() as PersonalTierKey) ?? 0
}
const isImmediate = computed(() => previewData.is_immediate)
const newIsYearly = computed(() =>
isAnnualDuration(previewData.new_plan.duration)
)
const currentIsYearly = computed(() =>
isAnnualDuration(previewData.current_plan?.duration)
)
const isCadenceChange = computed(
() =>
!!previewData.current_plan &&
previewData.current_plan.duration !== previewData.new_plan.duration
)
const newTierName = computed(() =>
teamPlan
? t('subscription.teamPlan.name')
: formatTierName(previewData.new_plan.tier)
)
const currentTierName = computed(() =>
previewData.current_plan ? formatTierName(previewData.current_plan.tier) : ''
)
const currentPlanLabel = computed(() =>
currentIsYearly.value
? t('subscription.tierNameYearly', { name: currentTierName.value })
: currentTierName.value
const newTierName = computed(() => formatTierName(previewData.new_plan.tier))
const currentDisplayPrice = computed(() =>
previewData.current_plan
? (previewData.current_plan.price_cents / 100).toFixed(0)
: '0'
)
const newMonthlyUsd = computed(() => {
const cents = previewData.new_plan.price_cents
return (newIsYearly.value ? cents / 12 : cents) / 100
})
const heroPrice = computed(() => newMonthlyUsd.value.toFixed(0))
const annualTotalFormatted = computed(
() => `$${n(previewData.new_plan.price_cents / 100)}`
const newDisplayPrice = computed(() =>
(previewData.new_plan.price_cents / 100).toFixed(0)
)
const newPlanPriceUsd = computed(() => previewData.new_plan.price_cents / 100)
const prorationCreditUsd = computed(() => {
const credit = previewData.new_plan.price_cents - previewData.cost_today_cents
return credit > 0 ? credit / 100 : 0
})
const totalDueTodayUsd = computed(() => previewData.cost_today_cents / 100)
const newMonthlyChargeUsd = computed(() => newMonthlyUsd.value)
const subscriptionLineLabel = computed(() =>
newIsYearly.value
? t('subscription.preview.yearlySubscription')
: t('subscription.preview.newMonthlySubscription')
const newMonthlyCharge = computed(() =>
(previewData.new_plan.price_cents / 100).toFixed(2)
)
const creditFromPlanLabel = computed(() => {
if (teamPlan) return t('subscription.preview.commitment')
return isCadenceChange.value
? t('subscription.preview.currentMonthly')
: currentTierName.value
const currentDisplayCredits = computed(() => {
if (!previewData.current_plan) return n(0)
const tierKey = previewData.current_plan.tier.toLowerCase() as
| 'standard'
| 'creator'
| 'pro'
return n(getTierCredits(tierKey) ?? 0)
})
const refillCredits = computed(() => {
const monthly = teamPlan
? teamPlan.credits
: tierMonthlyCredits(previewData.new_plan.tier)
return n(newIsYearly.value ? monthly * 12 : monthly)
const newDisplayCredits = computed(() => {
const tierKey = previewData.new_plan.tier.toLowerCase() as
| 'standard'
| 'creator'
| 'pro'
return n(getTierCredits(tierKey) ?? 0)
})
const monthlyRefillCredits = computed(() =>
n(teamPlan ? teamPlan.credits : tierMonthlyCredits(previewData.new_plan.tier))
)
const refillLabel = computed(() =>
newIsYearly.value
? t('subscription.preview.creditsYoullGetToday')
: t('subscription.preview.eachMonthCreditsRefill')
const currentPeriodEndDate = computed(() =>
previewData.current_plan?.period_end
? formatDate(previewData.current_plan.period_end)
: ''
)
const effectiveDate = computed(() => formatDate(previewData.effective_at))
const showProration = computed(() => previewData.is_immediate)
const proratedRefundCents = computed(() => {
if (!previewData.current_plan || !previewData.is_immediate) return 0
const chargeToday = previewData.cost_today_cents
const newPlanCost = previewData.new_plan.price_cents
if (chargeToday < newPlanCost) {
return newPlanCost - chargeToday
}
return 0
})
const proratedRefund = computed(() =>
(proratedRefundCents.value / 100).toFixed(2)
)
const proratedChargeCents = computed(() => {
if (!previewData.is_immediate) return 0
return previewData.cost_today_cents
})
const proratedCharge = computed(() =>
(proratedChargeCents.value / 100).toFixed(2)
)
const totalDueToday = computed(() =>
(previewData.cost_today_cents / 100).toFixed(2)
)
const effectiveDateLabel = computed(() => formatDate(previewData.effective_at))
const nextPaymentDate = computed(() =>
previewData.new_plan.period_end
? formatDate(previewData.new_plan.period_end)
: effectiveDateLabel.value
)
const currentPeriodEnd = computed(() =>
previewData.current_plan?.period_end
? formatDate(previewData.current_plan.period_end)
: effectiveDateLabel.value
)
const confirmTitle = computed(() =>
isImmediate.value
? t('subscription.preview.confirmUpgradeTitle')
: t('subscription.preview.confirmChangeTitle')
)
const confirmCta = computed(() =>
isImmediate.value
? t('subscription.preview.confirmUpgradeCta')
: t('subscription.preview.confirmChange')
)
const totalNote = computed(() =>
isImmediate.value
? t('subscription.preview.nextPaymentDue', { date: nextPaymentDate.value })
: t('subscription.preview.stayOnUntil', {
plan: currentPlanLabel.value,
date: currentPeriodEnd.value
})
: formatDate(previewData.effective_at)
)
</script>

View File

@@ -55,12 +55,12 @@ function renderComponent(props: Record<string, unknown> = {}) {
components: { Button },
stubs: {
SelectButton: { template: '<div />' },
// Clicking moves the v-model selection to a different stop ($200) so
// tests can move off the current stop.
// Clicking emits a change to a different stop ($200) so tests can move
// the selection off the current stop.
CreditSlider: {
template:
'<button data-testid="team-slider" @click="$emit(\'update:modelValue\', 200)" />',
emits: ['update:modelValue']
'<button data-testid="team-slider" @click="$emit(\'change\', { index: 0, usd: 200, credits: 42200 })" />',
emits: ['change', 'update:modelValue']
}
}
}
@@ -163,8 +163,7 @@ describe('UnifiedPricingTable team plan CTA', () => {
const cta = screen.getByRole('button', { name: 'Change plan' })
expect(cta).toBeEnabled()
await user.click(cta)
const [teamPayload] = emitted().subscribeTeam![0] as [{ isChange: boolean }]
expect(teamPayload).toMatchObject({ isChange: true })
expect(emitted().subscribeTeam).toBeTruthy()
})
it('lets an active sub change billing cycle at the current stop', async () => {
@@ -183,8 +182,7 @@ describe('UnifiedPricingTable team plan CTA', () => {
const cta = screen.getByRole('button', { name: 'Change plan' })
expect(cta).toBeEnabled()
await user.click(cta)
const [teamPayload] = emitted().subscribeTeam![0] as [{ isChange: boolean }]
expect(teamPayload).toMatchObject({ isChange: true })
expect(emitted().subscribeTeam).toBeTruthy()
expect(emitted().resubscribe).toBeFalsy()
})

View File

@@ -235,9 +235,8 @@
cycle halves the yearly discount when monthly. -->
<CreditSlider
v-model="teamUsd"
:stops="teamStops"
:default-stop-index="teamDefaultStopIndex"
:cycle="currentBillingCycle"
@change="onTeamChange"
/>
<!-- Selected credit grant + template-based video estimate -->
@@ -426,12 +425,10 @@ import type {
TierKey,
TierPricing
} from '@/platform/cloud/subscription/constants/tierPricing'
import { useBillingPlans } from '@/platform/cloud/subscription/composables/useBillingPlans'
import {
DEFAULT_TEAM_PLAN_STOP_INDEX,
TEAM_PLAN_CREDIT_STOPS,
getStopDiscountedMonthlyUsd,
mapApiTeamCreditStops
getDiscountedMonthlyUsd,
TEAM_PLAN_CREDIT_STOPS
} from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
import type { TeamPlanSelection } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
@@ -456,14 +453,14 @@ const {
const emit = defineEmits<{
subscribe: [payload: { tierKey: CheckoutTierKey; billingCycle: BillingCycle }]
resubscribe: []
subscribeTeam: [
payload: {
stop: TeamPlanSelection
billingCycle: BillingCycle
/** See `isTeamPlanChange`. */
isChange: boolean
}
]
// Team-plan checkout. NOTE: the slider stop -> plan-slug mapping is blocked on
// the BE discount-breakpoint contract (FE-934 / doc Open Q#2); the host shows
// the confirm step but stubs the final subscribe until the contract lands.
// TODO(FE-934): once the contract lands, also carry `currentBillingCycle`
// (yearly | monthly) so checkout subscribes to the selected cycle, not just
// the stop. The pricing-table view already toggles cycle; the confirm/checkout
// chain still assumes yearly.
subscribeTeam: [payload: TeamPlanSelection]
}>()
const { t, n } = useI18n()
@@ -615,40 +612,17 @@ const {
currentTeamCreditStop
} = useBillingContext()
const { teamCreditStops } = useBillingPlans()
const isCancelled = computed(() => subscription.value?.isCancelled ?? false)
const currentBillingCycle = ref<BillingCycle>('yearly')
// Team credit stops: backend-sourced when the API supplies them, otherwise the
// hardcoded DES-197 fallback so OSS / pre-deploy still renders. Always non-empty
// so the default/selected stops below are guaranteed defined.
const teamStops = computed(() => {
const apiStops = teamCreditStops.value?.stops
return apiStops?.length
? mapApiTeamCreditStops(apiStops)
: TEAM_PLAN_CREDIT_STOPS
})
const teamDefaultStopIndex = computed(
() =>
teamCreditStops.value?.default_stop_index ?? DEFAULT_TEAM_PLAN_STOP_INDEX
// Team plan selection (slider). Stop -> slug mapping is BE-blocked (see emit).
const teamUsd = ref<number>(
TEAM_PLAN_CREDIT_STOPS[DEFAULT_TEAM_PLAN_STOP_INDEX].usd
)
const defaultTeamStop = computed(
() => teamStops.value[teamDefaultStopIndex.value] ?? teamStops.value[0]
const teamCredits = ref<number>(
TEAM_PLAN_CREDIT_STOPS[DEFAULT_TEAM_PLAN_STOP_INDEX].credits
)
const teamUsd = ref<number>(defaultTeamStop.value.usd)
// The selected stop follows the slider's USD value; when it matches none (e.g.
// the API stops loaded after mount with different breakpoints) it falls back to
// the default stop so the id/credits stay consistent with what's displayed.
const selectedTeamStop = computed(
() =>
teamStops.value.find((stop) => stop.usd === teamUsd.value) ??
defaultTeamStop.value
)
const teamCredits = computed(() => selectedTeamStop.value.credits)
const teamVideoEstimate = computed(() =>
Math.round(teamCredits.value * VIDEO_PER_CREDIT)
)
@@ -656,16 +630,11 @@ const teamVideoEstimate = computed(() =>
// The team's currently-subscribed stop (null when on no team plan). Matched to
// the slider stops by list price so the current stop can be disabled.
const isTeamSubscribed = computed(() => currentTeamCreditStop.value !== null)
// `teamUsd` is seeded at mount from the fallback default; when the API stops
// resolve afterwards with different breakpoints that seed can match no stop,
// leaving the slider position and the subscribe payload out of sync. Snap to the
// resolved default — but only while no real stop is pinned (a subscriber's stop
// is set below; a user's own selection already matches a stop).
watch(defaultTeamStop, (stop) => {
if (currentTeamCreditStop.value) return
if (teamStops.value.some((s) => s.usd === teamUsd.value)) return
teamUsd.value = stop.usd
const currentTeamStopIndex = computed(() => {
const usd = currentTeamCreditStop.value?.stop_usd
if (usd == null) return null
const i = TEAM_PLAN_CREDIT_STOPS.findIndex((stop) => stop.usd === usd)
return i === -1 ? null : i
})
// Start the slider on the current stop so an active subscriber sees their plan
@@ -675,23 +644,19 @@ watch(
(stop) => {
if (!stop) return
teamUsd.value = stop.stop_usd
teamCredits.value = stop.credits_monthly
},
{ immediate: true }
)
// The CTA — not the slider stop — reflects the current plan: on the active stop
// it reads "Current plan" (disabled); a cancelled plan re-subscribes on its
// stop. Any other stop is locked because the credit stop can't be changed. The
// subscribed stop must be one of the available stops for the slider to land on
// it, so match against `teamStops` rather than the hardcoded fallback.
const isTeamCurrentStopSelected = computed(() => {
const usd = currentTeamCreditStop.value?.stop_usd
return (
usd != null &&
usd === teamUsd.value &&
teamStops.value.some((stop) => stop.usd === usd)
)
})
// stop. Any other stop is locked because the credit stop can't be changed.
const isTeamCurrentStopSelected = computed(
() =>
currentTeamStopIndex.value !== null &&
TEAM_PLAN_CREDIT_STOPS[currentTeamStopIndex.value]?.usd === teamUsd.value
)
// Yearly and monthly at the same credit stop are distinct plans, so toggling
// the cycle is a change, not the current plan.
@@ -728,13 +693,6 @@ const isTeamButtonDisabled = computed(
!isCancelled.value)
)
// A subscriber moving off their current plan is a prorated change rather than a
// fresh subscribe; re-subscribe and the locked current plan exit before the
// change emit, so this drives the prorated transition preview.
const isTeamPlanChange = computed(
() => isTeamSubscribed.value && !isTeamCurrentPlanSelected.value
)
onMounted(() => {
void fetchPlans()
})
@@ -846,6 +804,11 @@ function handleSubscribe(tierKey: CheckoutTierKey) {
emit('subscribe', { tierKey, billingCycle: currentBillingCycle.value })
}
function onTeamChange(stop: { index: number; usd: number; credits: number }) {
teamUsd.value = stop.usd
teamCredits.value = stop.credits
}
function handleSubscribeTeam() {
if (isTeamButtonDisabled.value) return
// Re-subscribe only when keeping the exact current plan; any other stop or
@@ -854,19 +817,13 @@ function handleSubscribeTeam() {
emit('resubscribe')
return
}
const stop = selectedTeamStop.value
emit('subscribeTeam', {
stop: {
id: stop.id,
usd: stop.usd,
credits: stop.credits,
discountedUsd: getStopDiscountedMonthlyUsd(
stop,
currentBillingCycle.value
)
},
billingCycle: currentBillingCycle.value,
isChange: isTeamPlanChange.value
usd: teamUsd.value,
credits: teamCredits.value,
discountedUsd: getDiscountedMonthlyUsd(
teamUsd.value,
currentBillingCycle.value
)
})
}

View File

@@ -1,132 +0,0 @@
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ChangeMemberRoleDialogContent from './ChangeMemberRoleDialogContent.vue'
import type { WorkspaceRole } from '@/platform/workspace/api/workspaceApi'
const { mockChangeMemberRole, mockCloseDialog, mockToastAdd } = vi.hoisted(
() => ({
mockChangeMemberRole: vi.fn(),
mockCloseDialog: vi.fn(),
mockToastAdd: vi.fn()
})
)
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
changeMemberRole: mockChangeMemberRole
})
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({
closeDialog: mockCloseDialog
})
}))
vi.mock('primevue/usetoast', () => ({
useToast: () => ({
add: mockToastAdd
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
function renderDialog(targetRole: WorkspaceRole) {
const user = userEvent.setup()
const result = render(ChangeMemberRoleDialogContent, {
props: { memberId: 'mem-1', memberName: 'Jane', targetRole },
global: { plugins: [i18n] }
})
return { ...result, user }
}
describe('ChangeMemberRoleDialogContent', () => {
beforeEach(() => {
vi.clearAllMocks()
mockChangeMemberRole.mockResolvedValue(undefined)
})
it('shows promote copy and confirms with Make owner', async () => {
const { user } = renderDialog('owner')
expect(
screen.getByText('workspacePanel.changeRoleDialog.promoteTitle')
).toBeInTheDocument()
expect(
screen.getByText('workspacePanel.changeRoleDialog.promoteIntro')
).toBeInTheDocument()
expect(screen.getAllByRole('listitem')).toHaveLength(3)
await user.click(
screen.getByRole('button', {
name: 'workspacePanel.changeRoleDialog.promoteConfirm'
})
)
expect(mockChangeMemberRole).toHaveBeenCalledWith('mem-1', 'owner')
await waitFor(() =>
expect(mockCloseDialog).toHaveBeenCalledWith({
key: 'change-member-role'
})
)
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
it('shows demote copy and confirms with Demote to member', async () => {
const { user } = renderDialog('member')
expect(
screen.getByText('workspacePanel.changeRoleDialog.demoteTitle')
).toBeInTheDocument()
expect(
screen.getByText('workspacePanel.changeRoleDialog.demoteMessage')
).toBeInTheDocument()
await user.click(
screen.getByRole('button', {
name: 'workspacePanel.changeRoleDialog.demoteConfirm'
})
)
expect(mockChangeMemberRole).toHaveBeenCalledWith('mem-1', 'member')
})
it('keeps the dialog open and toasts on failure', async () => {
mockChangeMemberRole.mockRejectedValue(new Error('boom'))
const { user } = renderDialog('owner')
await user.click(
screen.getByRole('button', {
name: 'workspacePanel.changeRoleDialog.promoteConfirm'
})
)
await waitFor(() =>
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
)
expect(mockCloseDialog).not.toHaveBeenCalled()
})
it('closes without changing the role on cancel', async () => {
const { user } = renderDialog('owner')
await user.click(screen.getByRole('button', { name: 'g.cancel' }))
expect(mockCloseDialog).toHaveBeenCalledWith({ key: 'change-member-role' })
expect(mockChangeMemberRole).not.toHaveBeenCalled()
})
})

View File

@@ -1,115 +0,0 @@
<template>
<div
class="flex w-full max-w-90 flex-col rounded-2xl border border-border-default bg-base-background"
>
<!-- Header -->
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h2 class="m-0 text-sm font-normal text-base-foreground">
{{
isPromotion
? $t('workspacePanel.changeRoleDialog.promoteTitle', {
name: memberName
})
: $t('workspacePanel.changeRoleDialog.demoteTitle', {
name: memberName
})
}}
</h2>
<button
class="focus-visible:ring-secondary-foreground -m-1 cursor-pointer rounded-sm border-none bg-transparent p-1 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:ring-1 focus-visible:outline-none"
:aria-label="$t('g.close')"
@click="onCancel"
>
<i class="pi pi-times size-4" />
</button>
</div>
<!-- Body -->
<div class="p-4">
<template v-if="isPromotion">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.changeRoleDialog.promoteIntro') }}
</p>
<ul class="m-0 mt-1 list-disc ps-5 text-sm text-muted-foreground">
<li v-for="permission in promotePermissions" :key="permission">
{{ permission }}
</li>
</ul>
</template>
<p v-else class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.changeRoleDialog.demoteMessage') }}
</p>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 p-4">
<Button variant="muted-textonly" @click="onCancel">
{{ $t('g.cancel') }}
</Button>
<Button variant="secondary" size="lg" :loading @click="onConfirm">
{{
isPromotion
? $t('workspacePanel.changeRoleDialog.promoteConfirm')
: $t('workspacePanel.changeRoleDialog.demoteConfirm')
}}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { WorkspaceRole } from '@/platform/workspace/api/workspaceApi'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const { memberId, targetRole } = defineProps<{
memberId: string
memberName: string
targetRole: WorkspaceRole
}>()
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
const toast = useToast()
const { t } = useI18n()
const loading = ref(false)
const isPromotion = computed(() => targetRole === 'owner')
const promotePermissions = computed(() => [
t('workspacePanel.changeRoleDialog.promotePermissionCredits'),
t('workspacePanel.changeRoleDialog.promotePermissionManage'),
t('workspacePanel.changeRoleDialog.promotePermissionRoles')
])
function onCancel() {
dialogStore.closeDialog({ key: 'change-member-role' })
}
async function onConfirm() {
loading.value = true
try {
await workspaceStore.changeMemberRole(memberId, targetRole)
toast.add({
severity: 'success',
summary: t('workspacePanel.changeRoleDialog.success'),
life: 2000
})
dialogStore.closeDialog({ key: 'change-member-role' })
} catch {
toast.add({
severity: 'error',
summary: t('workspacePanel.changeRoleDialog.error')
})
} finally {
loading.value = false
}
}
</script>

View File

@@ -1,186 +0,0 @@
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import InviteMemberDialogContent from './InviteMemberDialogContent.vue'
import type { PendingInvite } from '@/platform/workspace/stores/teamWorkspaceStore'
const { mockCreateInvite, mockCloseDialog, mockToastAdd } = vi.hoisted(() => ({
mockCreateInvite: vi.fn(),
mockCloseDialog: vi.fn(),
mockToastAdd: vi.fn()
}))
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
createInvite: mockCreateInvite
})
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({
closeDialog: mockCloseDialog
})
}))
vi.mock('primevue/usetoast', () => ({
useToast: () => ({
add: mockToastAdd
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
function pendingInviteFor(email: string): PendingInvite {
return {
id: `inv-${email}`,
email,
inviteDate: new Date(0),
expiryDate: new Date(0)
}
}
function renderDialog() {
const user = userEvent.setup()
const result = render(InviteMemberDialogContent, {
global: { plugins: [i18n] }
})
return { ...result, user }
}
function emailInput() {
return screen.getByRole('textbox')
}
function inviteButton() {
return screen.getByRole('button', { name: 'workspacePanel.invite' })
}
describe('InviteMemberDialogContent', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCreateInvite.mockImplementation(async (email: string) =>
pendingInviteFor(email)
)
})
it('turns comma- and enter-delimited input into chips', async () => {
const { user } = renderDialog()
await user.type(emailInput(), 'a@b.com,')
await user.type(emailInput(), 'c@d.com{Enter}')
expect(screen.getByText('a@b.com')).toBeInTheDocument()
expect(screen.getByText('c@d.com')).toBeInTheDocument()
})
it('splits a pasted comma-separated list into chips', async () => {
const { user } = renderDialog()
await user.click(emailInput())
await user.paste('a@b.com, c@d.com')
expect(screen.getByText('a@b.com')).toBeInTheDocument()
expect(screen.getByText('c@d.com')).toBeInTheDocument()
})
it('disables Invite while there are no chips', () => {
renderDialog()
expect(inviteButton()).toBeDisabled()
})
it('flags invalid emails and keeps Invite disabled', async () => {
const { user } = renderDialog()
await user.type(emailInput(), 'not-an-email{Enter}')
expect(screen.getByText('not-an-email')).toBeInTheDocument()
expect(
screen.getByText('workspacePanel.inviteMemberDialog.invalidEmailCount')
).toBeInTheDocument()
expect(inviteButton()).toBeDisabled()
await user.type(emailInput(), 'a@b.com{Enter}')
expect(inviteButton()).toBeDisabled()
})
it('creates an invite per email and shows the success state', async () => {
const { user } = renderDialog()
await user.type(emailInput(), 'a@b.com,c@d.com{Enter}')
await user.click(inviteButton())
expect(
await screen.findByText(
'workspacePanel.inviteMemberDialog.invitedMessage'
)
).toBeInTheDocument()
expect(mockCreateInvite).toHaveBeenCalledTimes(2)
expect(mockCreateInvite).toHaveBeenCalledWith('a@b.com')
expect(mockCreateInvite).toHaveBeenCalledWith('c@d.com')
const closeButton = screen
.getAllByRole('button', { name: 'g.close' })
.find((button) => button.textContent?.includes('g.close'))
await user.click(closeButton!)
expect(mockCloseDialog).toHaveBeenCalledWith({ key: 'invite-member' })
})
it('keeps only failed emails as chips and toasts on partial failure', async () => {
mockCreateInvite.mockImplementation(async (email: string) => {
if (email === 'fail@x.com') throw new Error('nope')
return pendingInviteFor(email)
})
const { user } = renderDialog()
await user.type(emailInput(), 'ok@x.com,fail@x.com{Enter}')
await user.click(inviteButton())
await waitFor(() => expect(mockCreateInvite).toHaveBeenCalledTimes(2))
expect(screen.getByText('fail@x.com')).toBeInTheDocument()
expect(screen.queryByText('ok@x.com')).not.toBeInTheDocument()
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
expect(inviteButton()).toBeEnabled()
})
it('stays on the form and keeps every chip when all invites fail', async () => {
mockCreateInvite.mockRejectedValue(new Error('nope'))
const { user } = renderDialog()
await user.type(emailInput(), 'a@b.com,c@d.com{Enter}')
await user.click(inviteButton())
await waitFor(() => expect(mockCreateInvite).toHaveBeenCalledTimes(2))
expect(
screen.queryByText('workspacePanel.inviteMemberDialog.invitedMessage')
).not.toBeInTheDocument()
expect(screen.getByText('a@b.com')).toBeInTheDocument()
expect(screen.getByText('c@d.com')).toBeInTheDocument()
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
expect(inviteButton()).toBeEnabled()
})
it('closes without inviting on Cancel', async () => {
const { user } = renderDialog()
await user.click(screen.getByRole('button', { name: 'g.cancel' }))
expect(mockCreateInvite).not.toHaveBeenCalled()
expect(mockCloseDialog).toHaveBeenCalledWith({ key: 'invite-member' })
})
})

View File

@@ -1,100 +1,97 @@
<template>
<div
class="flex w-full max-w-lg flex-col rounded-2xl border border-border-default bg-base-background"
class="flex w-full max-w-[512px] flex-col rounded-2xl border border-border-default bg-base-background"
>
<!-- Header -->
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h2 class="m-0 text-sm font-normal text-base-foreground">
{{ $t('workspacePanel.inviteMemberDialog.title') }}
{{
step === 'email'
? $t('workspacePanel.inviteMemberDialog.title')
: $t('workspacePanel.inviteMemberDialog.linkStep.title')
}}
</h2>
<button
class="focus-visible:ring-secondary-foreground cursor-pointer rounded-sm border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:ring-1 focus-visible:outline-none"
:aria-label="$t('g.close')"
@click="onClose"
@click="onCancel"
>
<i class="pi pi-times size-4" />
</button>
</div>
<template v-if="step === 'form'">
<div class="flex flex-col gap-2 p-4">
<TagsInput
always-editing
add-on-paste
add-on-blur
:delimiter="EMAIL_DELIMITER"
:convert-value="trimEmail"
:model-value="emails"
class="min-h-10 w-full bg-secondary-background"
@update:model-value="onEmailsUpdate"
>
<TagsInputItem
v-for="email in emails"
:key="email"
:value="email"
:class="
cn(
'rounded-full',
!EMAIL_REGEX.test(email) && 'bg-danger/20 text-danger'
)
"
>
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput
auto-focus
class="text-sm"
:placeholder="
emails.length === 0
? $t('workspacePanel.inviteMemberDialog.placeholder')
: undefined
"
/>
</TagsInput>
<p v-if="invalidEmails.length > 0" class="text-danger m-0 text-xs">
{{
$t(
'workspacePanel.inviteMemberDialog.invalidEmailCount',
invalidEmails.length
)
}}
<!-- Body: Email Step -->
<template v-if="step === 'email'">
<div class="flex flex-col gap-4 p-4">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.inviteMemberDialog.message') }}
</p>
<input
v-model="email"
type="email"
class="focus:ring-secondary-foreground w-full rounded-lg border border-border-default bg-transparent px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus:ring-1 focus:outline-none"
:placeholder="$t('workspacePanel.inviteMemberDialog.placeholder')"
/>
</div>
<!-- Footer: Email Step -->
<div class="flex items-center justify-end gap-4 p-4">
<Button variant="muted-textonly" @click="onClose">
<Button variant="muted-textonly" @click="onCancel">
{{ $t('g.cancel') }}
</Button>
<Button
variant="secondary"
variant="primary"
size="lg"
:loading
:disabled="!canSubmit"
@click="onInvite"
:disabled="!isValidEmail"
@click="onCreateLink"
>
{{ $t('workspacePanel.invite') }}
{{ $t('workspacePanel.inviteMemberDialog.createLink') }}
</Button>
</div>
</template>
<!-- Body: Link Step -->
<template v-else>
<div class="p-4">
<p class="m-0 text-sm/5 text-muted-foreground">
{{
$t(
'workspacePanel.inviteMemberDialog.invitedMessage',
{ emails: invitedEmails.join(', ') },
invitedEmails.length
)
}}
<div class="flex flex-col gap-4 p-4">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.inviteMemberDialog.linkStep.message') }}
</p>
<p class="m-0 text-sm font-medium text-base-foreground">
{{ email }}
</p>
<div class="relative">
<input
:value="generatedLink"
readonly
class="w-full cursor-pointer rounded-lg border border-border-default bg-transparent px-3 py-2 pr-10 text-sm text-base-foreground focus:outline-none"
@click="onSelectLink"
/>
<div
class="absolute top-2.5 right-3 cursor-pointer"
@click="onCopyLink"
>
<i
:class="
cn(
'pi size-4',
justCopied ? 'pi-check text-green-500' : 'pi-copy'
)
"
/>
</div>
</div>
</div>
<div class="flex items-center justify-end p-4">
<Button variant="secondary" size="lg" @click="onClose">
{{ $t('g.close') }}
<!-- Footer: Link Step -->
<div class="flex items-center justify-end gap-4 p-4">
<Button variant="muted-textonly" @click="onCancel">
{{ $t('g.cancel') }}
</Button>
<Button variant="primary" size="lg" @click="onCopyLink">
{{ $t('workspacePanel.inviteMemberDialog.linkStep.copyLink') }}
</Button>
</div>
</template>
@@ -107,73 +104,69 @@ import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import TagsInput from '@/components/ui/tags-input/TagsInput.vue'
import TagsInputInput from '@/components/ui/tags-input/TagsInputInput.vue'
import TagsInputItem from '@/components/ui/tags-input/TagsInputItem.vue'
import TagsInputItemDelete from '@/components/ui/tags-input/TagsInputItemDelete.vue'
import TagsInputItemText from '@/components/ui/tags-input/TagsInputItemText.vue'
import { cn } from '@comfyorg/tailwind-utils'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@comfyorg/tailwind-utils'
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const EMAIL_DELIMITER = /[,\s]+/
const dialogStore = useDialogStore()
const toast = useToast()
const { t } = useI18n()
const workspaceStore = useTeamWorkspaceStore()
const step = ref<'form' | 'invited'>('form')
const emails = ref<string[]>([])
const invitedEmails = ref<string[]>([])
const loading = ref(false)
const email = ref('')
const step = ref<'email' | 'link'>('email')
const generatedLink = ref('')
const justCopied = ref(false)
const invalidEmails = computed(() =>
emails.value.filter((email) => !EMAIL_REGEX.test(email))
)
const canSubmit = computed(
() => emails.value.length > 0 && invalidEmails.value.length === 0
)
const isValidEmail = computed(() => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email.value)
})
function trimEmail(value: string) {
return value.trim()
}
function onEmailsUpdate(value: string[]) {
emails.value = value.filter((email) => email.length > 0)
}
function onClose() {
function onCancel() {
dialogStore.closeDialog({ key: 'invite-member' })
}
async function onInvite() {
if (!canSubmit.value || loading.value) return
async function onCreateLink() {
if (!isValidEmail.value) return
loading.value = true
try {
const submitted = [...emails.value]
const results = await Promise.allSettled(
submitted.map((email) => workspaceStore.createInvite(email))
)
const failedEmails = submitted.filter(
(_, index) => results[index].status === 'rejected'
)
if (failedEmails.length === 0) {
invitedEmails.value = submitted
step.value = 'invited'
return
}
emails.value = failedEmails
generatedLink.value = await workspaceStore.createInviteLink(email.value)
step.value = 'link'
} catch (error) {
toast.add({
severity: 'error',
summary: t(
'workspacePanel.inviteMemberDialog.failedCount',
failedEmails.length
)
summary: t('workspacePanel.inviteMemberDialog.linkCopyFailed'),
detail: error instanceof Error ? error.message : undefined
})
} finally {
loading.value = false
}
}
async function onCopyLink() {
try {
await navigator.clipboard.writeText(generatedLink.value)
justCopied.value = true
setTimeout(() => {
justCopied.value = false
}, 759)
toast.add({
severity: 'success',
summary: t('workspacePanel.inviteMemberDialog.linkCopied'),
life: 2000
})
} catch {
toast.add({
severity: 'error',
summary: t('workspacePanel.inviteMemberDialog.linkCopyFailed')
})
}
}
function onSelectLink(event: Event) {
const input = event.target as HTMLInputElement
input.select()
}
</script>

View File

@@ -39,7 +39,11 @@
{{ $t('g.cancel') }}
</Button>
<Button variant="primary" size="lg" @click="onUpgrade">
{{ $t('workspacePanel.inviteUpsellDialog.upgradeToTeam') }}
{{
isActiveSubscription
? $t('workspacePanel.inviteUpsellDialog.upgradeToCreator')
: $t('workspacePanel.inviteUpsellDialog.viewPlans')
}}
</Button>
</div>
</div>

View File

@@ -1,6 +1,5 @@
<template>
<div
:data-testid="`member-row-${member.id}`"
:class="
cn(
'grid w-full items-center rounded-lg p-2',
@@ -16,76 +15,78 @@
:pt:icon:class="{ 'text-xl!': !isCurrentUser || !photoUrl }"
/>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<span class="text-sm text-base-foreground">
{{ member.name }}
<span v-if="isCurrentUser" class="text-muted-foreground">
({{ $t('g.you') }})
<div class="flex items-center gap-2">
<span class="text-sm text-base-foreground">
{{ member.name }}
<span v-if="isCurrentUser" class="text-muted-foreground">
({{ $t('g.you') }})
</span>
</span>
</span>
<RoleBadge v-if="showRoleBadge" :role="member.role" />
</div>
<span class="text-sm text-muted-foreground">
{{ member.email }}
</span>
</div>
</div>
<span
v-if="showRoleColumn && !isSingleSeatPlan"
v-if="showDateColumn && !isSingleSeatPlan"
class="text-right text-sm text-muted-foreground"
>
{{
member.role === 'owner'
? $t('workspaceSwitcher.roleOwner')
: $t('workspaceSwitcher.roleMember')
}}
{{ formatDate(member.joinDate) }}
</span>
<div
v-if="canManageMembers && !isSingleSeatPlan"
v-if="canRemoveMembers && !isSingleSeatPlan"
class="flex items-center justify-end"
>
<DropdownMenu
v-if="!isCurrentUser && !isOriginalOwner"
:entries="menuItems"
<Button
v-if="!isCurrentUser"
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="muted-textonly"
size="icon"
:aria-label="$t('g.moreOptions')"
@click="$emit('showMenu', $event)"
>
<template #button>
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="muted-textonly"
size="icon"
:aria-label="$t('g.moreOptions')"
>
<i class="pi pi-ellipsis-h" />
</Button>
</template>
</DropdownMenu>
<i class="pi pi-ellipsis-h" />
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import type { MenuItem } from 'primevue/menuitem'
import { useI18n } from 'vue-i18n'
import DropdownMenu from '@/components/common/DropdownMenu.vue'
import UserAvatar from '@/components/common/UserAvatar.vue'
import Button from '@/components/ui/button/Button.vue'
import RoleBadge from '@/platform/workspace/components/RoleBadge.vue'
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@comfyorg/tailwind-utils'
const {
showRoleColumn = false,
canManageMembers = false,
showRoleBadge = false,
showDateColumn = false,
canRemoveMembers = false,
isSingleSeatPlan = false,
isOriginalOwner = false,
striped = false,
menuItems = []
striped = false
} = defineProps<{
member: WorkspaceMember
isCurrentUser: boolean
photoUrl?: string
gridCols: string
showRoleColumn?: boolean
canManageMembers?: boolean
showRoleBadge?: boolean
showDateColumn?: boolean
canRemoveMembers?: boolean
isSingleSeatPlan?: boolean
isOriginalOwner?: boolean
striped?: boolean
menuItems?: MenuItem[]
}>()
defineEmits<{
showMenu: [event: Event]
}>()
const { d } = useI18n()
function formatDate(date: Date): string {
return d(date, { dateStyle: 'medium' })
}
</script>

View File

@@ -1,48 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import MemberUpsellBanner from './MemberUpsellBanner.vue'
const meta: Meta<typeof MemberUpsellBanner> = {
title: 'Platform/Workspace/MemberUpsellBanner',
component: MemberUpsellBanner,
tags: ['autodocs'],
argTypes: {
reactivate: { control: 'boolean' },
onShowPlans: { action: 'showPlans' }
},
args: {
reactivate: false
},
decorators: [
(story) => ({
components: { story },
template: '<div class="w-[720px] bg-base-background p-6"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
// Workspace that never subscribed to a team plan: acquisition copy.
export const Upgrade: Story = {
args: { reactivate: false }
}
// Team plan that was subscribed and has since been cancelled or ended: win-back
// copy (driven by hasLapsedTeamPlan → subscriptionStatus 'canceled' | 'ended').
export const Reactivate: Story = {
args: { reactivate: true }
}
export const BothStates: Story = {
render: () => ({
components: { MemberUpsellBanner },
template: `
<div class="flex flex-col gap-4">
<MemberUpsellBanner :reactivate="false" />
<MemberUpsellBanner :reactivate="true" />
</div>
`
})
}

View File

@@ -1,54 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import MemberUpsellBanner from './MemberUpsellBanner.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function renderBanner(props: { reactivate?: boolean } = {}) {
return render(MemberUpsellBanner, {
props,
global: { plugins: [i18n] }
})
}
describe('MemberUpsellBanner', () => {
it('shows upgrade copy when the workspace never subscribed', () => {
renderBanner()
expect(
screen.getByText('To add teammates, upgrade your plan.')
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Upgrade to Team' })
).toBeInTheDocument()
})
it('shows reactivate copy when the team plan has lapsed', () => {
renderBanner({ reactivate: true })
expect(
screen.getByText('To add more teammates, reactivate your plan.')
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Reactivate Team' })
).toBeInTheDocument()
})
it('emits showPlans when the CTA is clicked', async () => {
const user = userEvent.setup()
const { emitted } = renderBanner({ reactivate: true })
await user.click(screen.getByRole('button', { name: 'Reactivate Team' }))
expect(emitted()).toHaveProperty('showPlans')
})
})

View File

@@ -1,28 +1,20 @@
<template>
<div
class="mt-4 flex w-full items-center justify-between gap-4 rounded-2xl border border-interface-stroke bg-secondary-background p-6 max-sm:flex-col max-sm:items-stretch"
class="mt-4 flex items-center justify-center gap-2 rounded-xl border border-border-default bg-secondary-background px-4 py-3"
>
<div class="flex items-center gap-2">
<i class="icon-[lucide--info] size-4 shrink-0 text-muted-foreground" />
<p class="m-0 text-sm text-muted-foreground">
{{
reactivate
? $t('workspacePanel.members.upsellBannerReactivate')
: $t('workspacePanel.members.upsellBanner')
}}
</p>
</div>
<p class="text-foreground m-0 text-sm">
{{
isActiveSubscription
? $t('workspacePanel.members.upsellBannerUpgrade')
: $t('workspacePanel.members.upsellBannerSubscribe')
}}
</p>
<Button
variant="inverted"
size="lg"
class="max-sm:w-full"
variant="muted-textonly"
class="cursor-pointer text-sm underline"
@click="$emit('showPlans')"
>
{{
reactivate
? $t('workspacePanel.members.reactivateTeam')
: $t('workspacePanel.members.upgradeToTeam')
}}
{{ $t('workspacePanel.members.viewPlans') }}
</Button>
</div>
</template>
@@ -30,8 +22,8 @@
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
const { reactivate = false } = defineProps<{
reactivate?: boolean
defineProps<{
isActiveSubscription: boolean
}>()
defineEmits<{

View File

@@ -1,8 +1,7 @@
import { render, screen, within } from '@testing-library/vue'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Slots } from 'vue'
import { computed, h, ref } from 'vue'
import { computed, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import MembersPanelContent from './MembersPanelContent.vue'
@@ -12,26 +11,21 @@ import type {
WorkspaceMember
} from '../../../stores/teamWorkspaceStore'
const mockHandleResendInvite = vi.fn()
const mockHandleCopyInviteLink = vi.fn()
const mockHandleRevokeInvite = vi.fn()
const mockMemberMenuItems = vi.fn(() => [])
const mockHandleCreateWorkspace = vi.fn()
const mockShowTeamPlans = vi.fn()
const mockSelectMember = vi.fn()
const mockToggleSort = vi.fn()
const mockHandleInviteMember = vi.fn()
const {
mockMembers,
mockPendingInvites,
mockOriginalOwnerId,
mockFilteredMembers,
mockFilteredPendingInvites,
mockIsPersonalWorkspace,
mockIsOnTeamPlan,
mockHasMultipleMembers,
mockShowSearch,
mockShowViewTabs,
mockShowInviteButton,
mockIsInviteDisabled,
mockIsSingleSeatPlan,
mockIsActiveSubscription,
mockActiveView,
mockSearchQuery,
mockPermissions,
@@ -43,16 +37,11 @@ const {
return {
mockMembers: ref<WorkspaceMember[]>([]),
mockPendingInvites: ref<PendingInvite[]>([]),
mockOriginalOwnerId: ref<string | null>(null),
mockHasMultipleMembers: ref(true),
mockShowSearch: ref(true),
mockShowViewTabs: ref(true),
mockShowInviteButton: ref(true),
mockIsInviteDisabled: ref(false),
mockFilteredMembers: ref<WorkspaceMember[]>([]),
mockFilteredPendingInvites: ref<PendingInvite[]>([]),
mockIsPersonalWorkspace: ref(false),
mockIsOnTeamPlan: ref(true),
mockIsSingleSeatPlan: ref(false),
mockIsActiveSubscription: ref(true),
mockActiveView: ref<'active' | 'pending'>('active'),
mockSearchQuery: ref(''),
mockPermissions: ref({
@@ -60,7 +49,7 @@ const {
canViewPendingInvites: true,
canInviteMembers: true,
canManageInvites: true,
canManageMembers: true,
canRemoveMembers: true,
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: true,
@@ -70,7 +59,8 @@ const {
showMembersList: true,
showPendingTab: true,
showSearch: true,
showRoleColumn: true,
showDateColumn: true,
showRoleBadge: true,
membersGridCols: 'grid-cols-[50%_40%_10%]',
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
headerGridCols: 'grid-cols-[50%_40%_10%]',
@@ -86,14 +76,7 @@ vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
searchQuery: mockSearchQuery,
activeView: mockActiveView,
maxSeats: computed(() => 20),
isOnTeamPlan: mockIsOnTeamPlan,
hasMultipleMembers: mockHasMultipleMembers,
showSearch: mockShowSearch,
showViewTabs: mockShowViewTabs,
showInviteButton: mockShowInviteButton,
isInviteDisabled: mockIsInviteDisabled,
inviteTooltip: computed(() => null),
handleInviteMember: mockHandleInviteMember,
isSingleSeatPlan: mockIsSingleSeatPlan,
personalWorkspaceMember: computed(() => ({
id: 'self',
name: 'Owner User',
@@ -104,36 +87,26 @@ vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
})),
filteredMembers: mockFilteredMembers,
filteredPendingInvites: mockFilteredPendingInvites,
memberMenuItems: mockMemberMenuItems,
memberMenus: computed(
() =>
new Map(
mockFilteredMembers.value.map((m) => [m.id, mockMemberMenuItems()])
)
),
memberMenuItems: computed(() => []),
isPersonalWorkspace: mockIsPersonalWorkspace,
members: mockMembers,
pendingInvites: mockPendingInvites,
permissions: mockPermissions,
uiConfig: mockUiConfig,
isActiveSubscription: mockIsActiveSubscription,
userPhotoUrl: ref(null),
isCurrentUser: (m: WorkspaceMember) =>
m.email.toLowerCase() === 'owner@example.com',
isOriginalOwner: (m: WorkspaceMember) => m.id === mockOriginalOwnerId.value,
selectMember: mockSelectMember,
toggleSort: mockToggleSort,
showTeamPlans: mockShowTeamPlans,
handleResendInvite: mockHandleResendInvite,
handleCopyInviteLink: mockHandleCopyInviteLink,
handleRevokeInvite: mockHandleRevokeInvite,
handleRemoveMember: vi.fn(),
handleChangeRole: vi.fn()
handleCreateWorkspace: mockHandleCreateWorkspace,
handleRemoveMember: vi.fn()
})
}))
vi.mock('@/components/button/MoreButton.vue', () => ({
default: (_: unknown, { slots }: { slots: Slots }) =>
h('div', slots.default?.({ close: () => {} }))
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -165,7 +138,7 @@ function renderComponent() {
Button: ButtonStub,
SearchInput: SearchInputStub,
UserAvatar: true,
WorkspaceMenuButton: true
Menu: { template: '<div />', props: ['model', 'popup'] }
},
directives: { tooltip: () => {} }
}
@@ -190,6 +163,7 @@ function createInvite(overrides: Partial<PendingInvite> = {}): PendingInvite {
return {
id: 'invite-1',
email: 'invitee@example.com',
token: 'token-abc',
inviteDate: new Date('2025-03-01'),
expiryDate: new Date('2025-04-01'),
...overrides
@@ -199,19 +173,13 @@ function createInvite(overrides: Partial<PendingInvite> = {}): PendingInvite {
describe('MembersPanelContent', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMemberMenuItems.mockReturnValue([])
mockMembers.value = []
mockPendingInvites.value = []
mockOriginalOwnerId.value = null
mockFilteredMembers.value = []
mockFilteredPendingInvites.value = []
mockIsPersonalWorkspace.value = false
mockIsOnTeamPlan.value = true
mockHasMultipleMembers.value = true
mockShowSearch.value = true
mockShowViewTabs.value = true
mockShowInviteButton.value = true
mockIsInviteDisabled.value = false
mockIsSingleSeatPlan.value = false
mockIsActiveSubscription.value = true
mockActiveView.value = 'active'
mockSearchQuery.value = ''
mockPermissions.value = {
@@ -219,7 +187,7 @@ describe('MembersPanelContent', () => {
canViewPendingInvites: true,
canInviteMembers: true,
canManageInvites: true,
canManageMembers: true,
canRemoveMembers: true,
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: true,
@@ -229,7 +197,8 @@ describe('MembersPanelContent', () => {
showMembersList: true,
showPendingTab: true,
showSearch: true,
showRoleColumn: true,
showDateColumn: true,
showRoleBadge: true,
membersGridCols: 'grid-cols-[50%_40%_10%]',
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
headerGridCols: 'grid-cols-[50%_40%_10%]',
@@ -242,31 +211,27 @@ describe('MembersPanelContent', () => {
describe('personal workspace', () => {
beforeEach(() => {
mockIsPersonalWorkspace.value = true
mockIsOnTeamPlan.value = false
mockHasMultipleMembers.value = false
mockShowSearch.value = false
mockShowViewTabs.value = false
mockIsInviteDisabled.value = true
mockUiConfig.value.showMembersList = false
mockUiConfig.value.showSearch = false
mockUiConfig.value.showPendingTab = false
})
it('shows the upsell banner below the members card', () => {
it('shows personal workspace message and create workspace button', () => {
renderComponent()
expect(
screen.getByText('workspacePanel.members.upsellBanner')
screen.getByText('workspacePanel.members.personalWorkspaceMessage')
).toBeTruthy()
expect(
screen.getByText('workspacePanel.members.createNewWorkspace')
).toBeTruthy()
})
it('opens team plans on upgrade click', async () => {
it('calls handleCreateWorkspace when create button is clicked', async () => {
renderComponent()
await userEvent.click(
screen.getByRole('button', {
name: /workspacePanel\.members\.upgradeToTeam/
})
screen.getByText('workspacePanel.members.createNewWorkspace')
)
expect(mockShowTeamPlans).toHaveBeenCalled()
expect(mockHandleCreateWorkspace).toHaveBeenCalled()
})
it('does not show search input', () => {
@@ -276,19 +241,6 @@ describe('MembersPanelContent', () => {
})
describe('team workspace - member list', () => {
it('shows the Role column header and member roles', () => {
mockFilteredMembers.value = [
createMember({ role: 'owner', email: 'boss@test.com' }),
createMember({ id: '2', role: 'member', email: 'peer@test.com' })
]
renderComponent()
expect(
screen.getByText('workspacePanel.members.columns.role')
).toBeTruthy()
expect(screen.getByText('workspaceSwitcher.roleOwner')).toBeTruthy()
expect(screen.getByText('workspaceSwitcher.roleMember')).toBeTruthy()
})
it('renders filtered members', () => {
mockFilteredMembers.value = [
createMember({ name: 'Alice', email: 'alice@test.com' }),
@@ -322,29 +274,6 @@ describe('MembersPanelContent', () => {
screen.queryAllByRole('button', { name: 'g.moreOptions' })
).toHaveLength(0)
})
it('does not show more options on the original owner row', () => {
mockOriginalOwnerId.value = 'creator-1'
mockFilteredMembers.value = [
createMember({
id: 'creator-1',
name: 'Creator',
email: 'creator@test.com',
role: 'owner'
}),
createMember({ id: '2', name: 'Other', email: 'other@test.com' })
]
renderComponent()
const creatorRow = screen.getByTestId('member-row-creator-1')
const otherRow = screen.getByTestId('member-row-2')
expect(
within(creatorRow).queryByRole('button', { name: 'g.moreOptions' })
).toBeNull()
expect(
within(otherRow).getByRole('button', { name: 'g.moreOptions' })
).toBeInTheDocument()
})
})
describe('pending invites tab', () => {
@@ -356,107 +285,47 @@ describe('MembersPanelContent', () => {
).toBeTruthy()
})
it('triggers handleRevokeInvite from the row menu cancel item', async () => {
it('triggers handleRevokeInvite on revoke click', async () => {
mockActiveView.value = 'pending'
mockFilteredPendingInvites.value = [createInvite({ id: 'inv-42' })]
renderComponent()
await userEvent.click(
screen.getByRole('button', {
name: 'workspacePanel.members.actions.cancelInvite'
})
)
expect(mockHandleRevokeInvite).toHaveBeenCalledWith(
expect.objectContaining({ id: 'inv-42' })
)
const revokeBtn = screen.getByRole('button', {
name: 'workspacePanel.members.actions.revokeInvite'
})
await userEvent.click(revokeBtn)
expect(mockHandleRevokeInvite).toHaveBeenCalled()
})
it('triggers handleResendInvite from the row menu resend item', async () => {
it('triggers handleCopyInviteLink on copy click', async () => {
mockActiveView.value = 'pending'
mockFilteredPendingInvites.value = [createInvite({ id: 'inv-42' })]
renderComponent()
await userEvent.click(
screen.getByRole('button', {
name: 'workspacePanel.members.actions.resendInvite'
})
)
expect(mockHandleResendInvite).toHaveBeenCalledWith(
expect.objectContaining({ id: 'inv-42' })
)
const copyBtn = screen.getByRole('button', {
name: 'workspacePanel.members.actions.copyLink'
})
await userEvent.click(copyBtn)
expect(mockHandleCopyInviteLink).toHaveBeenCalled()
})
})
describe('member role', () => {
describe('single seat plan', () => {
beforeEach(() => {
mockPermissions.value = {
canViewOtherMembers: true,
canViewPendingInvites: false,
canInviteMembers: false,
canManageInvites: false,
canManageMembers: false,
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: false,
canTopUp: false
}
mockUiConfig.value.showPendingTab = false
})
it('hides the pending tab button', () => {
mockPendingInvites.value = [createInvite()]
renderComponent()
expect(
screen.queryByText(/workspacePanel\.members\.tabs\.pendingCount/)
).toBeNull()
})
it('does not show the pending invites header', () => {
mockActiveView.value = 'pending'
mockPendingInvites.value = [createInvite()]
renderComponent()
expect(
screen.queryByText(/workspacePanel\.members\.pendingInvitesCount/)
).toBeNull()
})
it('shows no action menus on member rows', () => {
mockFilteredMembers.value = [
createMember({ name: 'Other', email: 'other@test.com' })
]
renderComponent()
expect(
screen.queryAllByRole('button', { name: 'g.moreOptions' })
).toHaveLength(0)
})
})
describe('not on team plan', () => {
beforeEach(() => {
mockIsOnTeamPlan.value = false
mockShowSearch.value = false
mockShowViewTabs.value = false
mockIsSingleSeatPlan.value = true
})
it('shows upsell banner', () => {
renderComponent()
expect(
screen.getByText('workspacePanel.members.upsellBanner')
screen.getByText('workspacePanel.members.upsellBannerUpgrade')
).toBeTruthy()
})
it('hides the upsell banner when on a team plan', () => {
mockIsOnTeamPlan.value = true
it('opens team plans on view plans click', async () => {
renderComponent()
expect(
screen.queryByText('workspacePanel.members.upsellBanner')
).toBeNull()
})
it('opens subscription dialog on upgrade click', async () => {
renderComponent()
const upgradeBtn = screen.getByRole('button', {
name: /workspacePanel\.members\.upgradeToTeam/
const viewPlansBtn = screen.getByRole('button', {
name: /workspacePanel\.members\.viewPlans/
})
await userEvent.click(upgradeBtn)
await userEvent.click(viewPlansBtn)
expect(mockShowTeamPlans).toHaveBeenCalled()
})
@@ -464,36 +333,6 @@ describe('MembersPanelContent', () => {
renderComponent()
expect(screen.queryByRole('textbox')).toBeNull()
})
it('hides the contact us footer', () => {
renderComponent()
expect(screen.queryByText('workspacePanel.members.contactUs')).toBeNull()
})
})
describe('contact us footer', () => {
it('opens discord in a new tab for team workspaces on a team plan', async () => {
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
renderComponent()
expect(
screen.getByText('workspacePanel.members.needMoreMembers')
).toBeTruthy()
await userEvent.click(
screen.getByText('workspacePanel.members.contactUs')
)
expect(openSpy).toHaveBeenCalledWith(
'https://www.comfy.org/discord',
'_blank',
'noopener,noreferrer'
)
openSpy.mockRestore()
})
it('is hidden in personal workspaces', () => {
mockIsPersonalWorkspace.value = true
renderComponent()
expect(screen.queryByText('workspacePanel.members.contactUs')).toBeNull()
})
})
describe('member count display', () => {
@@ -509,42 +348,4 @@ describe('MembersPanelContent', () => {
).toBeTruthy()
})
})
describe('card header actions', () => {
it('invokes the invite flow from the header invite button', async () => {
renderComponent()
await userEvent.click(
screen.getByRole('button', { name: 'workspacePanel.inviteMember' })
)
expect(mockHandleInviteMember).toHaveBeenCalled()
})
it('hides the invite button without invite access', () => {
mockShowInviteButton.value = false
renderComponent()
expect(
screen.queryByRole('button', { name: 'workspacePanel.inviteMember' })
).toBeNull()
})
it('disables the invite button when gated', () => {
mockIsInviteDisabled.value = true
renderComponent()
const button = screen.getByRole('button', {
name: 'workspacePanel.inviteMember'
})
expect((button as HTMLButtonElement).disabled).toBe(true)
})
it('hides the view tabs for a lone owner', () => {
mockShowViewTabs.value = false
renderComponent()
expect(
screen.queryByText('workspacePanel.members.tabs.active')
).toBeNull()
expect(
screen.queryByText('workspacePanel.members.columns.role')
).toBeNull()
})
})
})

Some files were not shown because too many files have changed in this diff Show More