mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-25 17:17:19 +00:00
Compare commits
1 Commits
main
...
codex/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ede5556644 |
5
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
5
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -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'
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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' }))
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
195
src/components/dialog/content/setting/LegacyCreditsPanel.vue
Normal file
195
src/components/dialog/content/setting/LegacyCreditsPanel.vue
Normal 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>
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
}))
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface ErrorCardData {
|
||||
nodeId?: NodeExecutionId
|
||||
nodeTitle?: string
|
||||
graphNodeId?: string
|
||||
isSubgraphNode?: boolean
|
||||
errors: ErrorItem[]
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -130,6 +130,7 @@ function createErrorCard(
|
||||
nodeId,
|
||||
nodeTitle: nodeInfo.title,
|
||||
graphNodeId: nodeInfo.graphNodeId,
|
||||
isSubgraphNode: nodeId.includes(':'),
|
||||
errors: []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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('/')
|
||||
} 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,28 +0,0 @@
|
||||
export interface MonthlyCreditsUsage {
|
||||
/** Credits consumed from the monthly allowance (never negative). */
|
||||
used: number
|
||||
/** Fraction (0–1) 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 }
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 = '/'
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -133,7 +133,7 @@ export function useSettingUI(
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() => import('@/components/dialog/content/setting/CreditsPanel.vue')
|
||||
() => import('@/components/dialog/content/setting/LegacyCreditsPanel.vue')
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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<{
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user