Compare commits

...

234 Commits

Author SHA1 Message Date
dante01yoon
c0e88ba824 fix(billing): size unified pricing dialog via Reka contentClass
The unified pricing dialog passed PrimeVue-path props (style/pt) for width,
but the dialog default renderer is now Reka (#12593), which sizes via
size/contentClass. The dead style left the table capped at the default md
(576px) frame, so the 1280px content overflowed and shifted off-center.

Move width to a Reka contentClass (w-fit + max-w cap) matching the sibling
subscription dialogs; w-fit preserves per-step hugging.
2026-06-24 21:02:19 +09:00
dante01yoon
1d9ea071f1 test(billing): cover unified pricing dialog Reka sizing 2026-06-24 21:02:19 +09:00
dante01yoon
799655fc48 fix(billing): drop preview-backend pin; cloud FE calls its own origin
FE-991 had pinned api_base to a hardcoded pr-4378.testenvs backend (+ a ws
builder branch for it). That leaked into this integration via the merge, so the
preview called the wrong backend cross-origin. Revert to main's same-origin
api_base ('') so the deployed FE hits its own /api/* as-is.
2026-06-24 17:14:45 +09:00
GitHub Action
64284dcaac [automated] Apply ESLint and Oxfmt fixes 2026-06-24 07:49:34 +00:00
dante01yoon
1235faf250 Merge remote-tracking branch 'origin/main' into jaewon/billing-rework-v1-integration
# Conflicts:
#	src/platform/cloud/subscription/components/SubscribeButton.vue
#	src/platform/cloud/subscription/composables/useSubscriptionActions.ts
2026-06-24 16:45:50 +09:00
dante01yoon
33402333e0 feat(billing): add team-plan Stripe checkout deep link to integration
/cloud/subscribe?tier=team&stop=<id>&cycle=<monthly|yearly> routes through the
workspace billing subscribe (reuses the integration's TEAM_PLAN_SLUG_BY_CYCLE +
team_credit_stop_id contract) and full-page-redirects to Stripe.
2026-06-24 16:40:57 +09:00
dante01yoon
4c2f45814b fix(billing): reconcile FE-991 integration with current main
- telemetry: take main's refactored providers/registry; re-add the FE-768
  workspace-invite event (WorkspaceInviteMetadata + trackWorkspaceInviteSent)
- dialogService / useSubscriptionDialog: drop -X-theirs duplication artifacts
- SubscriptionAddPaymentPreview: use the integration's credits binding
2026-06-24 16:38:50 +09:00
dante01yoon
6ffe346cf8 Merge remote-tracking branch 'origin/jaewon/fe-991-e2e-integration-pass-for-billing-rework-v1-team-workspaces' into jaewon/billing-rework-v1-integration
# Conflicts:
#	apps/website/src/components/common/maskRevealButton.variants.ts
#	apps/website/src/components/common/pillButton.variants.ts
2026-06-24 16:26:07 +09:00
dante01yoon
23ffc89ff2 chore(billing): sync pricing deep-link loader to FE-1104 latest
Brings the single strip-before-await URL-cleanup refactor into the FE-991
integration branch so the deep link matches the FE-1104 PR for preview testing.
2026-06-24 13:05:14 +09:00
dante01yoon
e904ba98fb feat(billing): allow all plan transitions + drop personal->team reroute (FE-934)
Carry the unified pricing-table rollback (no credit-stop / billing-cycle /
cross-plan locks, facade planType/subscriptionLock removed) onto the
team-subscribe stack, and let a personal workspace subscribe to a team plan
directly instead of rerouting to create/switch a workspace. The legacy team
table keeps its existing "choose team" flow.
2026-06-23 09:05:13 +09:00
dante01yoon
93c6d26ad4 fix(billing): lock team billing-cycle change at the current stop (FE-934)
Yearly/monthly at the same credit stop is rejected by the BE
(TRANSITION_NOT_ALLOWED), so the off-cycle CTA now disables with
"Team plan currently can't be changed" instead of reading "Current plan".
2026-06-22 22:58:29 +09:00
dante01yoon
9745e4d84a feat(billing): lock cross-plan transitions in the unified pricing table (FE-934)
A new-team subscriber can't switch to a personal tier, and a team workspace on a
personal plan can't switch to team in place — both now disable the CTA with a
context-specific message driven by the facade subscriptionLock/planType. A
personal workspace keeps routing to create/switch a team workspace.
2026-06-22 15:48:51 +09:00
dante01yoon
f746534e60 feat(billing): add planType + subscriptionLock to the billing facade (FE-934)
Single classification of the active subscription (none/personal/new-team/
legacy-team) and the transitions it allows, so the pricing table consumes one
fact instead of re-deriving plan-vs-plan locks. new-team is keyed on the credit
stop (there is no 'team' SubscriptionTier); FREE and no-subscription collapse to
'none'; a cancelled-but-still-active plan keeps its type until billing ends.
2026-06-22 15:46:14 +09:00
dante01yoon
fe46beb137 feat(billing): keep legacy team subscribers on the per-member team pricing table (FE-934)
Existing per-member (legacy) team subscribers (hyphenated team- plan slug, no team_credit_stop) keep the old tier-based team pricing table instead of the new unified credit-slider table. Detection lives in the billing facade (isLegacyTeamPlan); routing forks in showPricingTable before the unified branch. Restores modal:false for the legacy dialog's body-teleported popover and adds the missing success step to the resurrected legacy dialog.
2026-06-22 14:58:59 +09:00
dante01yoon
7e779de532 feat(billing): lock team credit-stop change once subscribed (FE-934)
A team plan's credit stop can't be changed, so moving the slider off the active
stop now disables the CTA with "Credits currently can't be changed" instead of
an enabled "Change plan" — for both active and cancelled subscriptions. The only
actionable subscribed state remains re-subscribing on the current stop. Drops the
now-unused teamPlan.changePlan key.
2026-06-22 09:45:14 +09:00
dante01yoon
4e609999d4 test(billing): harden personal→team gate test against cold-run emit race (FE-991)
The new container gate test could fail on a cold-cache first run (onChooseTeam
observed 0 calls) due to a userEvent/happy-dom event-dispatch warm-up race; the
gate source is synchronous and correct. Wrap the positive assertions in
vi.waitFor so the emit lands deterministically.
2026-06-22 00:20:55 +09:00
dante01yoon
d526bcd3dc feat(billing): route personal-workspace team-plan pick to workspace create/switch (FE-991)
A team plan can't be subscribed in place from a personal workspace, so a
personal user choosing a team plan in the unified pricing table is routed to
the Team Workspaces create/switch dialog instead of advancing into team
checkout. Gate on isInPersonalWorkspace; team workspaces keep the normal flow.
2026-06-21 23:39:55 +09:00
dante01yoon
09dd1f4826 feat(billing): role-gate Plan & Credits menu and action buttons (FE-768)
Port of the FE-768 Plan & Credits redesign onto the integration base,
using canManageSubscriptionLifecycle for the original-owner gate.

- ... menu per role: original owner = Edit / Cancel plan / Delete;
  promoted owner = Edit / Leave; member = Leave; personal = Edit, plus
  Cancel plan + Delete once on a paid plan.
- Cancel/Delete are creator-or-personal-owner only; the creator can't
  leave; Delete is locked while a team subscription is active.
- Render the menu via the design-system DropdownMenu (dividers, danger
  Delete) to match Figma; cancelled state shows Manage billing +
  Reactivate plan; personal Free shows Subscribe, both with the menu.
2026-06-21 23:03:17 +09:00
dante01yoon
d29a0b4167 fix(billing): match team-upgrade invite block to Figma (FE-965)
- give the email input a persistent tertiary-background fill so it reads as a
  field at rest, not only on focus (Figma 3084-6116)
- add a showSubmit prop to InviteMembersForm and expose submit/canSubmit/loading
  so the success screen places "Send invites" in the footer next to Close,
  not glued under the input (Figma 3698-10316)
2026-06-21 01:11:45 +09:00
dante01yoon
13ca482b77 fix(billing): show the checkout success page after team/async subscribe (FE-934)
A Stripe-backed subscribe (needs_payment_method / pending_payment) only fired
the billing op poll; on completion billingOperationStore.handleSuccess closed
the pricing dialog and opened settings, so "You're all set" never showed. Team
subscribes were also gated out of the success step by a selectedTierKey-only
guard.

- await the billing op in the async checkout branches and advance to the
  success step instead of tearing the dialog down
- leave the subscription dialog open in handleSuccess (only top-ups close +
  surface settings); keep the success toast
- render the success step for team checkouts, sourcing the name, price and
  credits from the selected slider stop
- drive the team success invite block off the flat MAX_WORKSPACE_MEMBERS cap
2026-06-21 00:32:30 +09:00
dante01yoon
3d228e3306 fix(billing): cancelled team sub on a different stop reads Change plan, not Resubscribe (FE-934) 2026-06-20 23:09:27 +09:00
dante01yoon
456cf7227e fix(billing): keep both credits-row buttons inside the user popover
A cancelled-but-still-active personal subscription renders both "Add credits" and "Resubscribe" in the credits row. With a wide (7-digit) balance the second button overflowed the fixed-width w-80 popover. Make the popover width fluid (w-fit, clamped min-w-80..max-w-96) so it stays 320px in the common single-action case and only grows (to ~370px) to fit both buttons.

Adds a @cloud e2e that asserts the Resubscribe button stays within the popover bounds.
2026-06-20 23:04:09 +09:00
dante01yoon
298529895d fix(billing): gate team CTA on current stop (Current plan/Change plan), drop slider stop disable (FE-934) 2026-06-20 22:58:51 +09:00
dante01yoon
2d12005629 feat(billing): disable the active team stop in the pricing slider; cancelled re-subscribes (FE-934) 2026-06-20 22:33:34 +09:00
dante01yoon
fc18ae7f8a feat(billing): price team Plan & Credits header from subscribed credit stop (FE-768) 2026-06-20 20:29:32 +09:00
dante01yoon
dafcf206fb feat(billing): surface team_credit_stops + drive CreditsTile monthly total from current stop (FE-964) 2026-06-20 20:23:26 +09:00
dante01yoon
eef3d739aa Merge FE-768 test fix (invoice-card assertions) into FE-991 integration 2026-06-20 13:00:05 +09:00
dante01yoon
9a0becfd06 test(billing): drop invoice-card assertions after card removal (FE-768) 2026-06-20 12:59:19 +09:00
dante01yoon
de466b72e5 Merge FE-768 plan-credits tip (remove Next month invoice card) into FE-991 integration 2026-06-20 12:56:44 +09:00
dante01yoon
29df1542d5 feat(billing): remove Next month invoice card from Plan & Credits panel (FE-768) 2026-06-20 12:46:31 +09:00
dante01yoon
67ea86e60c fix(billing): reopen team pricing tab from Members Upgrade to Team (FE-934)
Members upsell 'Upgrade to Team' was re-wired to showSubscriptionDialog()
during the FE-768 members merge, dropping the FE-934 team-tab fix on this
integration branch. Restore showTeamPlans() -> show({ planMode: 'team' }).
2026-06-20 09:58:48 +09:00
dante01yoon
0ebf3618f6 chore(preview): repoint backend pin to pr-4378 ephemeral env [do not merge] 2026-06-20 09:03:31 +09:00
dante01yoon
b6909e37c7 chore(preview): pin backend to pr-4393 ephemeral env [do not merge]
Route all same-origin /api, websocket, and bootstrap /api/features calls to https://pr-4393.testenvs.comfy.org so the labeled preview build exercises that ephemeral backend. Throwaway, scoped to this DO NOT MERGE integration PR.
2026-06-20 08:30:49 +09:00
dante01yoon
0574c2971d feat(billing): inline "Invite your team" block on team-upgrade success (FE-965 / DES-394)
Renders an inline invite block in the team variant of the "You're all set" success card so a buyer can invite teammates right after a team-plan upgrade.

- New shared InviteMembersForm.vue (chips/TagsInput multi-email form)
- Team upgrades only; seats capped via useBillingContext.getMaxSeats
- workspace_invite_sent telemetry + success-card i18n
- Storybook story + mocks

Stacked on FE-934 #12975 (success-card host).

(cherry picked from commit af8a7a1f41)
2026-06-20 00:29:54 +09:00
dante01yoon
fd79281e43 fix(billing): await members fetch before the deep-link gate (FE-1104)
(cherry picked from commit 4cd6f2f8b9)
2026-06-19 23:40:23 +09:00
dante01yoon
ab082bf952 test(billing): add @cloud e2e for the pricing deep link (FE-1104)
(cherry picked from commit c9babcdb85)
2026-06-19 23:40:18 +09:00
dante01yoon
6c3856d8c4 feat(billing): deep link to open the pricing table (FE-1104)
Add a /?pricing=1 (and /?pricing=team / =personal) deep link that opens the
pricing table on app load, gated to the original owner via
canManageSubscriptionLifecycle. Members and promoted owners are a silent
no-op (param stripped, app loads normally). Mirrors the existing preserved-
query URL-loader pattern; survives the login redirect. Eligible opens emit
the subscription modal_opened telemetry with a new deep_link reason.

(cherry picked from commit 0708c01f79)
2026-06-19 23:40:12 +09:00
dante01yoon
2b9dd72d87 fix(workspace): only preload members for team workspaces
The watch now gates ensureMembersLoaded on activeWorkspace.type === 'team'
so personal/undefined workspaces never invoke the store loader. This keeps
consumer composables that mock the store minimally (useSubscriptionDialog)
working, while the store method stays the idempotent safe loader.

(cherry picked from commit dde38af722)
2026-06-19 23:40:07 +09:00
dante01yoon
a0c7127155 refactor(workspace): move members preload into store ensureMembersLoaded
Address review: own the team members-preload coordination in the store
instead of the composable. ensureMembersLoaded no-ops for personal /
already-loaded workspaces, dedupes in-flight calls, and logs failures so a
later call retries. Inline the redundant isCurrentUserOriginalOwner computed.

(cherry picked from commit 8278c9d874)
2026-06-19 23:40:00 +09:00
dante01yoon
78dcc16a3b fix(billing): repoint FE-965 team-success story to renamed creator fixture
The FE-934 single-plan-confirm cherry-pick renamed the stories' plan
fixtures (creatorPlan -> creatorMonthly/creatorAnnual); the FE-965
TeamSuccessWithInvite story still referenced the removed creatorPlan.
2026-06-19 23:38:18 +09:00
GitHub Action
6ec0f331b3 [automated] Apply ESLint and Oxfmt fixes
(cherry picked from commit f45b9e80ab)
2026-06-19 23:35:54 +09:00
dante01yoon
e3c3473f97 fix(billing): tertiary CTA surface + correct success check color (FE-934)
The confirm CTA used the `secondary` variant (charcoal-600), nearly
indistinguishable from the dialog surface. Add a `tertiary-background`
design-system token (charcoal-400 per the DES spec) and a matching Button
variant, and use it for the confirm CTAs. The success check pointed at the
undefined `success-foreground` token (no color); switch it to the defined
`success-background` (#00cd72).

(cherry picked from commit 5b113faa20)
2026-06-19 23:35:49 +09:00
dante01yoon
a3858b39c4 feat(billing): single-plan plan-change confirm + monthly billed note (FE-934)
Rewrite the plan-change confirm to the single-plan layout from the design
mockup: immediate upgrades show prorated line items with upfront (yearly)
or monthly credit refill; scheduled downgrades show "Starts {date}", $0
due today, and an "After that" block. Add the "Billed monthly" note to the
new-subscription confirm and Storybook stories covering each variation.

(cherry picked from commit d61ab982c5)
2026-06-19 23:35:32 +09:00
dante01yoon
47ec1de9d0 fix(test): use namespace type import for vi.mock importOriginal
(cherry picked from commit af2e9bbc7b)
2026-06-19 23:14:20 +09:00
dante01yoon
912320bb7d feat(workspace): reactivate-vs-upgrade copy for lapsed team plans (FE-768)
The members upsell banner read 'Upgrade to Team' regardless of prior subscription. Branch the copy on a lapsed team plan (subscription_status 'canceled' or 'ended') so cancelled/ended workspaces see 'Reactivate Team' / 'To add more teammates, reactivate your plan.' instead, while never-subscribed workspaces keep the upgrade copy.

- useTeamPlan: hasLapsedTeamPlan (team-only, canceled|ended)
- MemberUpsellBanner: reactivate prop branches banner + CTA copy
- i18n: upsellBannerReactivate, reactivateTeam
- tests + Storybook stories (Upgrade/Reactivate/BothStates)

(cherry picked from commit 4b4c3ccf92)
2026-06-19 23:14:10 +09:00
dante01yoon
17bea9d97c merge: FE-934 yearly confirm + back-nav/Backspace fixes into FE-991 integration
# Conflicts:
#	src/platform/workspace/components/SubscriptionRequiredDialogContentUnified.vue
2026-06-19 19:40:38 +09:00
dante01yoon
6389549199 fix(billing): preserve pricing-table selection on back, add Backspace (FE-934)
Render the pricing table with v-show instead of v-if so the plan mode,
billing cycle, and credit-stop selection survive a round trip to the
confirm step and back. Backspace on the confirm step mirrors the back
arrow (ignored while an editable element is focused).
2026-06-19 19:34:05 +09:00
dante01yoon
2bbbf65321 fix(billing): align yearly checkout confirm to updated Figma (FE-934)
Yearly headline now shows the monthly-equivalent price with a
"{total} Billed yearly" subtitle and "Each year credits refill to" (x12);
monthly is unchanged. Pass the selected billing cycle to the team confirm
so the subtitle renders, drop the redundant header credits/month line, and
reword "Starting today" to "Starts today".
2026-06-19 18:24:43 +09:00
dante01yoon
b86f24bd3c merge: FE-768 members-invite-ui latest (#12759) into FE-991 integration
Resolve DropdownMenu z-index conflict with FE-770 (5a7ff04b01): FE-768's
dynamic contentStyle (lift past the current top modal) supersedes FE-770's
static z-3000 bump, so keep the static base at z-1700 per FE-768's design.
2026-06-19 17:51:24 +09:00
dante01yoon
75da9212d1 chore(billing): remove preview billing mock harness
The in-app harness was a preview-only stand-in while the team-workspaces
backend was unavailable. The real backend now serves workspaces, billing
status, plans and the team-workspaces flag, so the mock is no longer needed.
2026-06-19 16:01:12 +09:00
dante01yoon
757156b09d fix(billing): keep pricing dialog size constant across plan toggle (FE-934)
The unified pricing dialog hugged its content, so switching For Personal / For Teams resized it (personal ~1108px, team grew to 95vw, with differing heights). Pin the pricing step's content root to a fixed width (1280) and a min-height floor so both plan scopes render at the same size; the compact confirm/success steps still shrink to their content.
2026-06-19 15:38:09 +09:00
dante01yoon
b2b1fe8730 fix(ui): stack DropdownMenu popover above modal dialogs (FE-768)
The body-portaled menu kept a static z-1700, so it lost to dialogs that
join @primeuix's auto-incrementing 'modal' counter (z-1701+) and rendered
behind them (e.g. the workspace members more-options menu inside Settings).
Lift the open menu past the current top overlay when one exists; otherwise
keep the static z-index. Covered by a behavioral z-index test.
2026-06-19 14:05:39 +09:00
dante01yoon
f528e6b8b6 merge: FE-966 latest (#12953) into FE-991 integration
- runtime billing-type reinit fix (watch [workspaceId, type] -> reinit on type flip)
- adopt FE-966 flag-only billing dispatch: drop the stale personalWorkspaceBillingReady gate (absent on main + FE-966-latest; leftover from an earlier FE-966 merge)
- keep integrated #12975 subscribe(planSlug, options) signature + FE-964 topup validation
- useBillingContext.test.ts taken from FE-966-latest (flag-only reactive mock + reinit tests)
2026-06-19 12:16:52 +09:00
dante01yoon
88133b0b56 merge: team-subscribe credit-stop wire (#12975) into FE-991 integration
- subscribe() signature change rippled into FE-977 useDowngradeToPersonal (3-arg -> planSlug + {returnUrl,cancelUrl})
- members-panel files kept at FE-991 redesign (FE-768/770); team-subscribe upsell tweak dropped as base-drift
2026-06-19 11:31:01 +09:00
dante01yoon
74799d2e94 test(billing): assert reinit from a settled legacy init
Await initialize() before flipping the flag so the test exercises true
reinitialization from an initialized legacy context rather than first-time
workspace activation. Still red without the type watch (verified locally).
2026-06-19 11:00:53 +09:00
Dante
2b642e77f6 Merge branch 'main' into jaewon/fe-966-b1-single-billing-path-drop-isinpersonalworkspace-route 2026-06-19 10:43:35 +09:00
dante01yoon
0073063bdb fix(billing): drop unused exports and mock subscription dialog in members test 2026-06-19 10:42:23 +09:00
dante01yoon
9fe12cf975 fix(billing): reinitialize billing context when billing type flips at runtime
The init watch only tracked the active workspace id, so when the resolved
billing type flipped after setup (e.g. team-workspaces flag resolving from
authenticated remote config), the active backend swapped without running its
initialize(), leaving subscription/balance stale or empty.

Watch the resolved type alongside the workspace id and reinitialize on either.
2026-06-19 10:11:12 +09:00
dante01yoon
a4e53bdbe1 test(billing): reinitialize billing when resolved type flips at runtime 2026-06-19 10:11:02 +09:00
dante01yoon
a5781c483b Merge remote-tracking branch 'origin/jaewon/fe-934-unified-pricing-table' into jaewon/fe-934-team-subscribe-wire
# Conflicts:
#	src/platform/cloud/subscription/constants/teamPlanCreditStops.ts
#	src/platform/workspace/components/UnifiedPricingTable.vue
#	src/platform/workspace/composables/useSubscriptionCheckout.test.ts
2026-06-19 10:07:13 +09:00
Dante
3b07b5e1c4 Merge branch 'main' into jaewon/fe-966-b1-single-billing-path-drop-isinpersonalworkspace-route 2026-06-19 09:55:22 +09:00
dante01yoon
739b873dd6 fix(billing): show the cycle-discounted team price in the confirm step
UnifiedPricingTable already imports getDiscountedMonthlyUsd and emits discountedUsd, but the helper and the TeamPlanSelection.discountedUsd field were missing (broke typecheck) and the confirm step still showed the list price. Add the helper, carry discountedUsd on the selection, and display it.

FE-934.
2026-06-19 09:35:59 +09:00
dante01yoon
c1b42b2743 fix(billing): invite dialog sizing/copy, cancel-sub copy, members Contact-us link
Mirror of FE-965 #12954 onto the FE-991 integration branch.

- Invite members dialog: definite width (w-lg) so it stops stretching while typing; match Cancel button size to Invite

- Invite success copy now reads 'Invites were emailed to {emails}.' (DES-394)

- Cancel-subscription copy: unused monthly credits expire, additional credits kept

- Members footer 'Contact us' opens comfy.org/cloud/enterprise (was Discord)
2026-06-19 09:34:11 +09:00
dante01yoon
e804967e1e feat(billing): wire team-plan subscribe to BE-1254 credit-stop contract
Replace the team checkout 'coming soon' stub with a real subscribe call now that BE-1254 ships team_credit_stops on GET /api/billing/plans and team_credit_stop_id + billing_cycle on POST /api/billing/subscribe.

Source the slider stops from the API with the hardcoded DES-197 stops as a fallback, send { plan_slug: team_per_credit_{monthly,annual}, team_credit_stop_id, billing_cycle }, and handle the response like the personal path. preview-subscribe is unchanged; the team confirm step is display-only.

FE-934 follow-up.
2026-06-19 09:18:59 +09:00
dante01yoon
18eced3ea0 fix(billing): open team pricing tab from Upgrade to Team CTAs (FE-934) 2026-06-19 09:02:37 +09:00
dante01yoon
54b869db95 fix(billing): drop member count from enterprise upsell copy (FE-934) 2026-06-19 09:02:10 +09:00
dante01yoon
6183bc5edc merge: FE-768 plan-credits tip into FE-991 (creator-only cancel reconciled)
Brings the workspace Plan & Credits panel tip (free-tier 10 min runtime, Leave workspace menu) into the integration branch.

Reconciles the cancel-permission model with FE-770 per the Billing V1 decision (Slack/Notion): cancel and resubscribe are original-owner-only (canManageSubscriptionLifecycle); other owners and members can leave. Unifies the Leave-disabled check onto the canonical store isCurrentUserOriginalOwner, dropping the stale joinDate heuristic.

WorkspacePanelContent.vue kept at HEAD (FE-768 members/FE-769 already superseded the in-header invite/menu layout #12761 carried).
2026-06-19 01:29:45 +09:00
dante01yoon
759e3e2daa merge: FE-969 billing-history /api/billing/events (#12955) into FE-991 2026-06-19 00:40:36 +09:00
dante01yoon
195d244211 feat: source usage history from /api/billing/events behind teamWorkspacesEnabled
Wire the previously-dead workspaceApi.getBillingEvents into UsageLogsTable, selected by teamWorkspacesEnabled (legacy /customers/events kept for flag-off). Recognize topup_completed in topupTracker so credit top-up detection works on the unified feed.
2026-06-19 00:31:22 +09:00
dante01yoon
145ffd4c0c merge: re-sync FE-878 precondition routing tip (#12785) into FE-991 2026-06-19 00:14:29 +09:00
dante01yoon
666f2a368c merge: re-sync FE-934 unified pricing table tip (#12666) into FE-991
# Conflicts:
#	src/platform/workspace/components/SubscriptionSuccessWorkspace.test.ts
#	src/storybook/mocks/useBillingContext.ts
2026-06-19 00:06:17 +09:00
dante01yoon
0234944159 merge: FE-967 personal dialog UX + FE-966 single billing path into FE-991 integration
# Conflicts:
#	src/platform/cloud/subscription/composables/useSubscriptionDialog.test.ts
#	src/platform/cloud/subscription/composables/useSubscriptionDialog.ts
#	src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.test.ts
#	src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.vue
2026-06-19 00:03:44 +09:00
dante01yoon
eced564a5d merge: team subscribe allSettled (#12951) into FE-991 integration 2026-06-19 00:01:31 +09:00
dante01yoon
08f4b0f926 fix(billing): validate topup amount in the integration preview (FE-991) 2026-06-18 23:47:23 +09:00
dante01yoon
7ce3276f76 refactor(billing): drop personal legacy->workspace delegation, superseded by FE-966 flip
FE-966 routes personal through useWorkspaceBilling directly once
personal_workspace_billing_ready is on, so useLegacyBilling no longer
needs to delegate subscribe/preview/plans to /api/billing. Keep FE-967
scoped to the personal dialog rendering (isPersonal).
2026-06-18 23:43:18 +09:00
dante01yoon
120010863d merge: stack FE-967 on FE-966 single billing path
# Conflicts:
#	src/composables/billing/useBillingContext.test.ts
#	src/platform/cloud/subscription/composables/useSubscriptionDialog.test.ts
#	src/platform/cloud/subscription/composables/useSubscriptionDialog.ts
2026-06-18 23:41:21 +09:00
dante01yoon
56e5e2971e fix(billing): gate personal billing cutover on a backend-readiness flag (FE-966) 2026-06-18 22:21:10 +09:00
dante01yoon
83f3ee65ae feat(billing): collapse billing dispatch to flag-only single path (B1 / FE-966) 2026-06-18 21:14:12 +09:00
GitHub Action
bd95849e4b [automated] Apply ESLint and Oxfmt fixes 2026-06-18 11:38:03 +00:00
dante01yoon
ffc7643e9e test(billing): replace double type-cast with typed preview fixture 2026-06-18 20:32:52 +09:00
dante01yoon
9968380f1c Merge remote-tracking branch 'origin/jaewon/fe-770-promote-demote-workspace-members-owners-settings-members' into jaewon/fe-991-e2e-integration-pass-for-billing-rework-v1-team-workspaces 2026-06-18 20:32:38 +09:00
dante01yoon
68de563907 Merge remote-tracking branch 'origin/main' into jaewon/fe-991-e2e-integration-pass-for-billing-rework-v1-team-workspaces
# Conflicts:
#	browser_tests/tests/workspaceSwitcher.spec.ts
#	src/platform/cloud/subscription/composables/useSubscriptionDialog.ts
#	src/platform/workspace/composables/useWorkspaceBilling.test.ts
2026-06-18 20:31:16 +09:00
dante01yoon
ba72b5c4e8 fix(billing): keep successful team subscribe when post-write refresh fails
Mirror the personal/legacy adapter fix: the workspace subscribe write already
succeeded, so a post-write status/balance refresh failure now surfaces a soft
error via Promise.allSettled instead of rejecting and prompting a retry of an
already-active subscription.
2026-06-18 20:31:08 +09:00
dante01yoon
675d04af2b test(workspace): fix stale promote-dialog copy assertions (FE-770)
Promote dialog now renders promoteIntro + a permissions list instead of the single promoteMessage sentence; update the unit and cloud e2e assertions to match the rendered copy.
2026-06-18 20:08:12 +09:00
dante01yoon
e8e5333c8c fix(billing): keep successful personal subscribe when refresh fails
Use Promise.allSettled for the post-subscribe status/balance refresh so a refresh failure no longer rejects an already-successful subscribe (which would surface a misleading retry). Surfaces a soft error via the error ref instead.
2026-06-18 19:50:33 +09:00
dante01yoon
1984d57365 Merge remote-tracking branch 'origin/jaewon/fe-977-team-plan-downgrade-personal-member-removal-flow' into jaewon/fe-991-e2e-integration-pass-for-billing-rework-v1-team-workspaces
# Conflicts:
#	src/platform/workspace/api/workspaceApi.ts
2026-06-18 19:24:31 +09:00
dante01yoon
2a05922de5 Merge remote-tracking branch 'origin/jaewon/fe-978-owner-lifecycle-gating' into jaewon/fe-991-e2e-integration-pass-for-billing-rework-v1-team-workspaces
# Conflicts:
#	src/platform/workspace/api/workspaceApi.ts
2026-06-18 19:22:25 +09:00
dante01yoon
5e17feb1e8 Merge remote-tracking branch 'origin/jaewon/fe-770-promote-demote-workspace-members-owners-settings-members' into jaewon/fe-991-e2e-integration-pass-for-billing-rework-v1-team-workspaces 2026-06-18 19:21:28 +09:00
dante01yoon
83e269a802 feat(billing): gate cancel/reactivate to the original owner (FE-978)
Owner Cancel (plan menu) + Resubscribe (panel button and the workspace popover
re-activate path) now require canManageSubscriptionLifecycle (original owner)
instead of canManageSubscription. Promoted (non-creator) owners keep manage-
payment / upgrade / top-up; they no longer see cancel/reactivate. Per the
billing-permission matrix (FE SSOT).

Stacked on the permission infra (#12829). DRAFT / do-not-merge until the BE
is_creator flag ships: with it absent the permission fails closed, which would
hide cancel/reactivate from every owner (regression).
2026-06-18 19:18:38 +09:00
dante01yoon
53b77adf41 fix(billing): fail-closed downgrade member-removal + optional is_original_owner type (FE-977) 2026-06-18 19:17:12 +09:00
dante01yoon
0092a45983 fix(workspace): type is_original_owner as optional to match the BE contract (FE-770) 2026-06-18 19:16:02 +09:00
dante01yoon
2fb9313f7c fix(workspace): fail-closed creator guard + optional is_original_owner type (FE-770) 2026-06-18 19:15:48 +09:00
dante01yoon
0dea3481ec test(billing): reflect is_original_owner repoint into FE-991 integration preview 2026-06-18 16:55:39 +09:00
dante01yoon
0913c88f8e Merge remote-tracking branch 'origin/jaewon/fe-977-team-plan-downgrade-personal-member-removal-flow' into jaewon/fe-991-e2e-integration-pass-for-billing-rework-v1-team-workspaces 2026-06-18 16:53:54 +09:00
dante01yoon
3a480d17e7 Merge remote-tracking branch 'origin/jaewon/fe-978-owner-lifecycle-gating' into jaewon/fe-991-e2e-integration-pass-for-billing-rework-v1-team-workspaces
# Conflicts:
#	src/platform/workspace/api/workspaceApi.ts
#	src/platform/workspace/components/SubscriptionPanelContentWorkspace.vue
#	src/platform/workspace/components/TopUpCreditsDialogContentWorkspace.vue
2026-06-18 16:53:48 +09:00
dante01yoon
917f1e8e53 Merge remote-tracking branch 'origin/jaewon/fe-770-promote-demote-workspace-members-owners-settings-members' into jaewon/fe-991-e2e-integration-pass-for-billing-rework-v1-team-workspaces 2026-06-18 16:48:23 +09:00
dante01yoon
c31774b54d fix(billing): set workspace name heading to font-weight 600 (FE-768) 2026-06-18 16:15:44 +09:00
dante01yoon
6711b3d11f feat(workspace): protect only original owner on downgrade to personal (FE-977)
Add per-member is_original_owner/isOriginalOwner signal and repoint the
downgrade-to-personal removal off the local owner/email heuristic onto it,
so promoted (non-creator) owners are removed and only the original owner
is protected.
2026-06-18 16:15:41 +09:00
dante01yoon
ec3dcf1c94 feat(billing): gate cancel/reactivate to the original owner (FE-978)
Owner Cancel (plan menu) + Resubscribe (panel button and the workspace popover
re-activate path) now require canManageSubscriptionLifecycle (original owner)
instead of canManageSubscription. Promoted (non-creator) owners keep manage-
payment / upgrade / top-up; they no longer see cancel/reactivate. Per the
billing-permission matrix (FE SSOT).

Stacked on the permission infra (#12829). DRAFT / do-not-merge until the BE
is_creator flag ships: with it absent the permission fails closed, which would
hide cancel/reactivate from every owner (regression).
2026-06-18 16:11:06 +09:00
dante01yoon
e1f251479c feat(workspace): back row-lock with Member.is_original_owner (FE-770) 2026-06-18 15:33:33 +09:00
dante01yoon
66d0b773d9 feat(billing): route personal subscription-required through preview checkout (FE-967) 2026-06-18 15:25:09 +09:00
dante01yoon
a51183ae8a fix(workspace): gate creator lifecycle on members-list original owner (FE-770) 2026-06-18 15:25:09 +09:00
Dante
0f645514ac Merge branch 'jaewon/fe-768-members-invite-ui' into jaewon/fe-770-promote-demote-workspace-members-owners-settings-members 2026-06-18 14:20:46 +09:00
dante01yoon
55c00900dc fix(billing): show the creator's Leave option disabled, not hidden (FE-768) 2026-06-18 12:22:29 +09:00
dante01yoon
81e0bd04b3 fix(workspace): let non-creator owners leave; show creator's Leave disabled (FE-768) 2026-06-18 12:19:52 +09:00
dante01yoon
93bb2a0e9e fix(workspace): block changing the workspace creator's role (FE-770) 2026-06-18 12:14:57 +09:00
dante01yoon
96ac29b871 docs(workspace): reword ingest-types TODO without PR-scoped phrasing (FE-770)
Drop the confusing "this PR" reference per review; the comment outlives the PR,
so describe the swap condition (OpenAPI exposes is_creator) as a plain TODO.
2026-06-18 09:55:23 +09:00
dante01yoon
37270850f9 fix(billing): let members and non-creator owners leave from Plan & Credits (FE-768) 2026-06-18 00:17:27 +09:00
dante01yoon
c4871bf432 fix(billing): gate members panel on team-plan subscription, not tier (FE-768)
The panel keyed search / view tabs / member count / invite on the subscription
tier (seats > 1), so a standard-tier team workspace hid them all. Decouple from
tier:

- isOnTeamPlan = a team workspace subscribed to the (tier-independent) team plan.
- Search shows whenever there is more than one member.
- "{n} of N members" and the Pending tab show on the team plan.
- Invite is disabled off the team plan, when cancelled, or at the member cap.
- Member cap is a flat 30 (MAX_WORKSPACE_MEMBERS 50 -> 30), matching pricing.

(cherry picked from commit 0c8a8af6d9)
2026-06-18 00:05:31 +09:00
dante01yoon
0c8a8af6d9 fix(billing): gate members panel on team-plan subscription, not tier (FE-768)
The panel keyed search / view tabs / member count / invite on the subscription
tier (seats > 1), so a standard-tier team workspace hid them all. Decouple from
tier:

- isOnTeamPlan = a team workspace subscribed to the (tier-independent) team plan.
- Search shows whenever there is more than one member.
- "{n} of N members" and the Pending tab show on the team plan.
- Invite is disabled off the team plan, when cancelled, or at the member cap.
- Member cap is a flat 30 (MAX_WORKSPACE_MEMBERS 50 -> 30), matching pricing.
2026-06-17 23:56:52 +09:00
dante01yoon
9a3bad326c Merge FE-768 menu-unify + more-options button size into fe-991 integration
# Conflicts:
#	src/platform/workspace/components/dialogs/settings/WorkspaceMenuButton.vue
2026-06-17 23:31:33 +09:00
dante01yoon
2f3a827b45 Merge FE-770 promote-dialog DES update into fe-991 integration 2026-06-17 23:30:57 +09:00
GitHub Action
a0b254b982 [automated] Apply ESLint and Oxfmt fixes 2026-06-17 13:48:51 +00:00
dante01yoon
98e558cf3d Merge remote-tracking branch 'origin/main' into jaewon/fe-934-unified-pricing-table
# Conflicts:
#	src/platform/cloud/subscription/composables/useSubscriptionDialog.ts
2026-06-17 22:43:03 +09:00
dante01yoon
8c098ee213 docs(workspace): note generated ingest-types swap once is_creator ships (FE-770)
WorkspaceWithRole + WorkspaceWithRoleSchema are hand-rolled only because the
cloud ingest OpenAPI doesn't expose is_creator yet. @comfyorg/ingest-types
already generates the type + zWorkspaceWithRole; swap to them in this PR once
the spec ships the field.
2026-06-17 22:32:27 +09:00
dante01yoon
e30d35e655 fix(billing): size the members more-options button to match Invite (FE-768)
The workspace more-options (...) button used size="icon" (32px) next to the
size="lg" (40px) Invite button. Use icon-lg so both are 40px.
2026-06-17 21:50:16 +09:00
dante01yoon
881cf3a663 fix(billing): size the members more-options button to match Invite (FE-768)
The workspace more-options (...) button used size="icon" (32px) next to the
size="lg" (40px) Invite button. Use icon-lg so both are 40px.
2026-06-17 21:48:49 +09:00
dante01yoon
341c8469fa fix(billing): also shrink the switcher row button so the name truncates (FE-769)
The name span had min-w-0 but its parent <button> (flex flex-1) did not, so
the button kept its content width and the long name was hard-clipped by the
panel's overflow-hidden instead of truncating with an ellipsis.
2026-06-17 20:07:41 +09:00
dante01yoon
1bc10fa6dc docs(workspace): mark is_creator shape confirmed by BE (FE-770)
BE confirmed the current-user-relative is_creator boolean and that the
creator is tracked explicitly (not by creation date). Field stays optional
and gating fails closed until it actually ships on /api/workspaces.
2026-06-17 19:58:33 +09:00
dante01yoon
766c6ded82 fix(billing): workspace plan panel DES QA (FE-768)
- More-options is a square icon-lg button matching the adjacent actions.
- "View more details" reuses the muted-textonly link style (trailing icon,
  no underline) for consistency with the footer links.
- Tighten the workspace header -> tabs gap to 24px.
- Plan & Credits / Members tabs reserve their bold width so switching no
  longer resizes them.
2026-06-17 19:44:42 +09:00
dante01yoon
65f5c02b16 fix(billing): pricing dialog opens on the matching plan tab (FE-934)
A team workspace lands on For Teams, a personal workspace on For Personal,
instead of always defaulting to For Personal.

(cherry picked from commit 6f2ed144e7)
2026-06-17 19:39:11 +09:00
dante01yoon
33495bd015 fix(workspace): unify the workspace menu onto the shared DropdownMenu (FE-768)
WorkspaceMenuButton used PrimeVue Menu while the member-row '...' menu uses the shared DropdownMenu, so the two context menus looked different. Switch WorkspaceMenuButton to DropdownMenu so both are the same component. Add optional per-item 'tooltip' and 'class' support to the shared DropdownItem so the disabled-delete explanation tooltip and the destructive (red) delete styling are preserved.
2026-06-17 19:32:11 +09:00
dante01yoon
071780c27c fix(billing): update promote-to-owner dialog to DES design (FE-770)
Replace the single-paragraph promote message with the designed 'They'll be able to:' intro + bullet list of owner permissions (add credits; manage members, payment methods, and workspace settings; promote/demote other owners except the creator). Demote copy unchanged.
2026-06-17 18:54:37 +09:00
dante01yoon
785f1bbf8c fix(billing): keep default padding on the view-more link (FE-768)
Dropping px-0 (added for left-edge alignment) left the link with no
horizontal padding; use the muted-textonly button's default padding so it
matches the footer links.
2026-06-17 16:29:46 +09:00
dante01yoon
6f2ed144e7 fix(billing): pricing dialog opens on the matching plan tab (FE-934)
A team workspace lands on For Teams, a personal workspace on For Personal,
instead of always defaulting to For Personal.
2026-06-17 16:14:00 +09:00
dante01yoon
564255fb32 fix(billing): truncate long workspace names in the switcher (FE-769)
The name span needs min-w-0 in its flex row to truncate; without it a long
name widens the switcher panel off-screen.
2026-06-17 16:13:47 +09:00
dante01yoon
1473639f85 fix(billing): workspace plan panel DES QA (FE-768)
- More-options is a square icon-lg button matching the adjacent actions.
- "View more details" reuses the muted-textonly link style (trailing icon,
  no underline) for consistency with the footer links.
- Tighten the workspace header -> tabs gap to 24px.
- Plan & Credits / Members tabs reserve their bold width so switching no
  longer resizes them.
2026-06-17 16:13:34 +09:00
dante01yoon
19fb019555 fix(billing): credits tile DES QA polish (FE-964)
- Baseline-align "remaining" with the total and widen the gap.
- Drop the gray stroke on the out-of-credits notice (not a reused card).
- Add-credits buttons use the lg (40px) size to match the plan actions.
2026-06-17 16:13:19 +09:00
dante01yoon
e2b67ecca1 fix(billing-mock): include created_at/joined_at in mock workspaces
sortWorkspaces compares these dates via .localeCompare, so omitting them
threw once the 2nd-workspace (multiWs) toggle was on, aborting workspace init
and leaving the account menu blank. The real API always sends both. The
in-app preview harness now mirrors that shape so every scenario renders.
2026-06-17 15:28:03 +09:00
dante01yoon
1231e8d8c0 fix(billing): inactive plan-scope tab = active tab at 50% opacity (FE-934)
The For Personal / For Teams inactive tab used a half-opacity fill plus a
separate muted text colour, so it wasn't a faded copy of the active tab.
Render the inactive tab with the active tab's fill and text at opacity-50
(hover restores full), per the DES "50% opacity of the active tab" note.

(cherry picked from commit c0f3f6fb69)
2026-06-17 14:50:31 +09:00
dante01yoon
c0f3f6fb69 fix(billing): inactive plan-scope tab = active tab at 50% opacity (FE-934)
The For Personal / For Teams inactive tab used a half-opacity fill plus a
separate muted text colour, so it wasn't a faded copy of the active tab.
Render the inactive tab with the active tab's fill and text at opacity-50
(hover restores full), per the DES "50% opacity of the active tab" note.
2026-06-17 14:50:05 +09:00
dante01yoon
75131c2614 refactor(billing): drop unused precedence helper, reuse RuntimeErrorInfo (FE-878) 2026-06-17 11:25:47 +09:00
dante01yoon
5261bb361e fix(billing): drop underline on the pricing footnote "see details" link (FE-934)
The "see details" link is the only <a> in the footnote, so it picked up the
browser-default underline while the sibling <button> links (questions /
enterprise discussions / click here) render none. Add no-underline to match.

(cherry picked from commit a7c8f3ad82)
2026-06-17 11:25:44 +09:00
dante01yoon
9ef8266bae fix(billing): address DES pricing-table link feedback (FE-934)
- Make the "To add teammates..." personal subtitle a button that switches to the For Teams tab (was a non-interactive span)
- Point the pricing footnote "questions" link to the Pylon question form
- Fade the inactive plan-scope tab to 50% opacity of the active tab, keeping muted text
- Align the "see details" footnote link styling with the other links

(cherry picked from commit 76422f6bc2)
2026-06-17 11:25:44 +09:00
dante01yoon
a7c8f3ad82 fix(billing): drop underline on the pricing footnote "see details" link (FE-934)
The "see details" link is the only <a> in the footnote, so it picked up the
browser-default underline while the sibling <button> links (questions /
enterprise discussions / click here) render none. Add no-underline to match.
2026-06-17 11:23:39 +09:00
dante01yoon
76422f6bc2 fix(billing): address DES pricing-table link feedback (FE-934)
- Make the "To add teammates..." personal subtitle a button that switches to the For Teams tab (was a non-interactive span)
- Point the pricing footnote "questions" link to the Pylon question form
- Fade the inactive plan-scope tab to 50% opacity of the active tab, keeping muted text
- Align the "see details" footnote link styling with the other links
2026-06-17 11:05:31 +09:00
dante01yoon
4fd8acc4ea fix(billing): DES QA polish on the unified pricing table (FE-934)
Design feedback from the FE-991 verification pass:

- Match the team-plan CTA font to the personal CTAs (text-sm/normal; it
  was inheriting text-xs from the default button size).
- Give the Yearly/Monthly toggle buttons a min-width so the active pill
  doesn't resize when switching — the discount badge made Yearly wider
  than Monthly, which read as a shift.
- Left-align the credit-slider "Save X% ($Y)" badge when the team column
  wraps on narrow/mobile widths (was right-aligned via ms-auto), with
  more spacing above "Billed yearly".
- Free-tier users now see "Subscribe to {plan}" instead of "Change to
  {plan}"; only an active paid plan offers a change.

Adds a UnifiedPricingTable regression test for the free-tier label.

(cherry picked from commit 1c97eb016c)
2026-06-17 10:51:47 +09:00
dante01yoon
d9f54420b6 fix(billing): complete the storybook BillingContext mock (FE-934)
The mock fell behind the extended BillingContext interface
(billingStatus, subscriptionStatus, tier, renewalDate, resubscribe,
topup) after the main merge, breaking `pnpm typecheck`. Return the same
inert values the real facade exposes so the stub stays in lockstep.
2026-06-17 10:39:36 +09:00
dante01yoon
1c97eb016c fix(billing): DES QA polish on the unified pricing table (FE-934)
Design feedback from the FE-991 verification pass:

- Match the team-plan CTA font to the personal CTAs (text-sm/normal; it
  was inheriting text-xs from the default button size).
- Give the Yearly/Monthly toggle buttons a min-width so the active pill
  doesn't resize when switching — the discount badge made Yearly wider
  than Monthly, which read as a shift.
- Left-align the credit-slider "Save X% ($Y)" badge when the team column
  wraps on narrow/mobile widths (was right-aligned via ms-auto), with
  more spacing above "Billed yearly".
- Free-tier users now see "Subscribe to {plan}" instead of "Change to
  {plan}"; only an active paid plan offers a change.

Adds a UnifiedPricingTable regression test for the free-tier label.
2026-06-17 10:39:36 +09:00
dante01yoon
af110fc3a2 fix(billing): set free tier max runtime to 10 min per DES 3253-16079 (FE-768) 2026-06-15 18:18:05 +09:00
Dante
f8993d2279 Merge branch 'main' into jaewon/fe-878-bug-subscription-required-error-message-feels-choppyabrupt 2026-06-15 15:44:28 +09:00
dante01yoon
dc6c48c6dd fix(billing): route queue paywall (402) to modal, out of error panel (FE-878)
The free-tier queue paywall arrives on the POST /prompt path as { type: PAYMENT_REQUIRED, message: "Subscription required to queue workflows" }, which the execution_error-only routing did not cover, so it still surfaced as a raw error in the panel. Teach the runtime matcher this message and short-circuit the queuePrompt catch to open the precondition modal, keeping it out of the error panel and error count. Add an e2e regression test for the queue paywall plus a control for ordinary queue errors.

Fixes #12840
2026-06-15 12:36:23 +09:00
dante01yoon
9e7043e4ee feat(billing): sync DES QA polish into the integration preview (FE-991)
Mirrors the FE-934 unified-pricing-table QA polish (padding, base-background
content well, attached plan-scope tabs, 320px centered cards, 'Save up to 20%',
muted text, tabular-nums, white slider, fixed/scroll-safe dialog height, footer
spacing) into the e2e preview. Also commits the FE-978 SubscriptionInactiveMember
Dialog that the integrated useSubscriptionDialog imports, so the preview builds.
Dev-only billing mock (index.html/vite/harness) intentionally left uncommitted.
2026-06-14 10:09:36 +09:00
dante01yoon
548e260be0 fix(billing): apply DES QA polish to the unified pricing table (FE-934)
Designer QA (Alex, 2026-06-13/14) on the team-workspaces pricing dialog:
- reduce dialog/content padding to spec (16px sides / 24px top)
- one base-background content well (no border) holds the description, billing
  toggle and cards on a uniform surface; plan-scope tabs sit flush on top of it
  (no pill container, flat-bottom active tab)
- description moved above the billing toggle; add the missing personal
  description ('Personal plans are for individual use only. To add teammates,
  subscribe to the team plan.')
- 'Save up to 20%' for teams / 'Save 20%' for personal
- tier cards fixed at 320px and centered; 24px gap between cards
- remove the green outline on the popular card; footer links drop the underline
  and use the base-foreground token
- mute feature/perk and enterprise body text; tabular-nums on figures
- white credit-slider range/handle; fixed dialog height (constant across the
  personal/team toggle) that scrolls instead of clipping at narrow widths;
  16px footnote spacing
2026-06-14 10:06:39 +09:00
dante01yoon
b4ee092fd3 fix(workspace): pass is_creator through the shared type + auth schema (FE-770)
CodeRabbit (#12829): is_creator was declared on api/workspaceApi WorkspaceWithRole
but the duplicate type in workspaceTypes.ts and the Zod schema in
workspaceAuthStore stripped it, so the flag could be dropped on the auth/session
parse path. Align both so the original-owner flag survives. Still ASSUMED / BE
spec not finalized (FE-770 Q3 / BE-1337); optional, fails closed.
2026-06-13 21:00:12 +09:00
dante01yoon
d8774b7221 refactor(workspace): address Members invite UI review feedback (FE-768)
- drop redundant disabled class on invite Button (Button base already sets
  disabled:opacity-50; cursor-not-allowed is dead under pointer-events-none)
- dedupe PendingInvitesList menuItemClass against Button base styles
- clarify personal-workspace maxSeats clamp test description
2026-06-13 20:46:35 +09:00
dante01yoon
6583e65dca feat(workspace): creator-only canManageSubscriptionLifecycle permission (FE-770)
Adds a creator-only subscription-lifecycle permission so cancel / reactivate /
downgrade can be gated to the workspace's original owner (any owner keeps
canManageSubscription for manage-payment / top-up / change-commit), per the
billing-permission matrix in the FE SSOT.

ASSUMES /api/workspaces exposes a current-user-relative is_creator flag — BE
spec is NOT finalized (field shape + original-owner determination are open,
FE-770 Q3 / BE-1337). Fails closed: when is_creator is absent the lifecycle
permission is false, so no behavior changes until the BE signal lands. Code
comments mark every assumption point for revisit. Consumers (FE-978 cancel/
reactivate, FE-977 downgrade) wire to this once it is available.
2026-06-13 20:34:55 +09:00
Dante
b1d5ff8094 Merge branch 'main' into jaewon/fe-934-unified-pricing-table 2026-06-12 23:52:17 +09:00
dante01yoon
c9b77dd0ee feat(billing): sync FE-934 fixes into the integration preview
Bring the preview current with #12666: struck price on the right (#1) and the
team Yearly/Monthly toggle + monthly-discount-halved slider (#3).
2026-06-12 22:52:50 +09:00
dante01yoon
4e596e5e5f test(billing): in-app mock harness for preview testing (FE-991, do not merge)
Ports the billing-v1-verify userscript into the app so the preview deployment
exposes the draggable scenario panel with no Tampermonkey/CDP. Inert unless
opted in via ?billingmock. Works in the prod preview build by merging only
team_workspaces_enabled onto the real /api/features (preserving firebase_config
to avoid the logout trap); billing/workspace endpoints get canned responses.
2026-06-12 22:52:23 +09:00
dante01yoon
8526c5c872 feat(billing): add Yearly/Monthly toggle to the team plan (FE-934)
Team pricing was yearly-only (slider). Per PRD: GA Team Billing the team
plan has both cycles; monthly halves the yearly discount (0/2.5/5/7.5/10%
vs 0/5/10/15/20%). Ungate the cycle toggle for team, thread the cycle into
CreditSlider, and switch the CTA/billed line by cycle.
2026-06-12 22:28:44 +09:00
dante01yoon
9c6dd9c5a5 fix(billing): place struck 'before' price to the right of the current price (FE-934) 2026-06-12 19:38:51 +09:00
dante01yoon
232310e4b8 Merge commit '853c1164ba' into jaewon/fe-991-e2e-integration-pass-for-billing-rework-v1-team-workspaces
# Conflicts:
#	src/storybook/mocks/useBillingContext.ts
2026-06-12 17:05:56 +09:00
dante01yoon
5681b5d9e6 Merge commit 'cebae667ce' into jaewon/fe-991-e2e-integration-pass-for-billing-rework-v1-team-workspaces 2026-06-12 17:03:01 +09:00
dante01yoon
ef1d6293db Merge commit 'dfac66a2bc' into jaewon/fe-991-e2e-integration-pass-for-billing-rework-v1-team-workspaces 2026-06-12 17:03:01 +09:00
dante01yoon
e2f48b6120 Merge commit 'a7734f1734' into jaewon/fe-991-e2e-integration-pass-for-billing-rework-v1-team-workspaces 2026-06-12 17:03:01 +09:00
dante01yoon
893d734ee1 Merge commit 'e7e60537a5' into jaewon/fe-991-e2e-integration-pass-for-billing-rework-v1-team-workspaces 2026-06-12 17:03:00 +09:00
dante01yoon
c5fd2a4117 test(billing): type checkout-step story fixtures to the preview contract (FE-934 review) 2026-06-12 15:57:48 +09:00
dante01yoon
11b54604c5 test(workspace): isolate member-panel test refs + use owner fixtures (FE-768 review) 2026-06-12 15:53:42 +09:00
dante01yoon
cd48dc9de9 feat(billing): inline "Invite your team" block on team-upgrade success (FE-965 / DES-394) 2026-06-12 13:55:51 +09:00
dante01yoon
5248addf15 Merge remote-tracking branch 'origin/jaewon/fe-768-members-invite-ui' into jaewon/fe-965-open-invite-members-dialog-on-successful-team-plan-upgrade 2026-06-12 13:40:42 +09:00
dante01yoon
bdfd94da3c test(billing): cover cancel dialog error/success flow (FE-970)
Locks the dialog half of the intentional behavior change: a rejected
cancelSubscription (failure or timeout) surfaces an error toast and keeps
the dialog open; success closes the dialog with a success toast.
2026-06-12 10:31:18 +09:00
dante01yoon
e00b8cffe8 docs(billing): tighten FE-977 downgrade comments 2026-06-12 10:28:23 +09:00
dante01yoon
61085d5134 Merge remote-tracking branch 'origin/main' into jaewon/fe-934-unified-pricing-table
# Conflicts:
#	src/platform/cloud/subscription/constants/teamPlanCreditStops.ts
2026-06-12 10:27:41 +09:00
dante01yoon
1ce8c5876d fix(billing): harden downgrade fast-path and payment-tab failure modes (FE-977)
- toast (not unhandled-reject) when the no-dialog fast path or member
  refresh fails, mirroring the dialog path's error UX
- throw when the payment tab is popup-blocked after the awaited removal
  chain (window.open result was discarded); retry re-subscribes fast
- pin showDowngradeToPersonalDialog behavior in a dialogService-level
  test: refresh-before-decision, non-dismissable dialog props, fast-path
  toast
2026-06-12 08:52:27 +09:00
dante01yoon
c7da4241e5 feat(billing): render team 'Confirm your payment' step from the slider stop (FE-934)
Team subscribe previously dead-ended in a 'coming soon' toast at the
pricing table. It now advances to the DES-197 confirm step, rendered
display-only from the selected slider stop ({usd, credits}) via a
teamPlan prop on SubscriptionAddPaymentPreviewWorkspace — team perks in
the expandable features list, credits/month under the price, total due
today from the stop. The final subscribe CTA stays stubbed until the BE
slider contract lands (doc Open Q#2).
2026-06-12 08:43:44 +09:00
dante01yoon
64b4410365 fix(billing): align plan-change confirm to DES-197 — equal plan columns, next-cycle section, shared terms note (FE-934)
The current-plan column was fixed at w-[250px], squeezing the new-plan
column against the right edge and wrapping its start date (the Creator
side misalignment). Both columns are now flex-1 around a fixed arrow,
matching the Figma two-column grid.

Also restructures the middle section to the DES-197 layout — 'Every
month starting {date}' header with 'Credits refill to' and 'You'll be
charged' rows — and extracts the terms-agreement note into
SubscriptionTermsNote, now shown on both confirm screens.
2026-06-12 08:43:16 +09:00
dante01yoon
594145d31a fix(billing): validate and complete the downgrade subscribe flow (FE-977)
- gate on previewSubscribe before any member removal; surface the BE reason
- handle needs_payment_method (payment tab + billing-op polling) and
  pending_payment subscribe outcomes instead of discarding the response
- refresh the member list before deciding the no-members fast path
- block ESC/overlay-click dismissal of the confirm dialog mid-operation
- correct the creator-detection doc comment (FE-770 reconciliation note)
2026-06-12 08:35:07 +09:00
dante01yoon
bd4694185d fix(billing): always settle the billing op terminal promise (FE-970)
- handleSuccess: a rejected post-success status/balance refresh left the
  terminal promise unresolved, hanging cancelSubscription and locking the
  cancel dialog open; use Promise.allSettled so success still resolves
- startOperation: a duplicate start for an in-flight op resolved
  immediately with a pending snapshot, which the cancel path read as
  failure; join the in-flight terminal promise instead
2026-06-12 08:16:52 +09:00
dante01yoon
271d3ea571 Merge remote-tracking branch 'origin/jaewon/fe-977-team-plan-downgrade-personal-member-removal-flow' into jaewon/fe-991-e2e-integration-pass-for-billing-rework-v1-team-workspaces 2026-06-11 22:37:56 +09:00
dante01yoon
1b187e8acb Merge remote-tracking branch 'origin/jaewon/fe-878-bug-subscription-required-error-message-feels-choppyabrupt' into jaewon/fe-991-e2e-integration-pass-for-billing-rework-v1-team-workspaces 2026-06-11 22:37:37 +09:00
dante01yoon
cca62e703a Merge remote-tracking branch 'origin/jaewon/fe-978-team-plan-cancel-inactive-states-run-locked-members-retained' into jaewon/fe-991-e2e-integration-pass-for-billing-rework-v1-team-workspaces 2026-06-11 22:37:31 +09:00
dante01yoon
5d882c6364 fix(billing): void floating startOperation promise after FE-932/FE-970 integration (FE-991) 2026-06-11 22:37:16 +09:00
dante01yoon
1ac4f2764d Merge remote-tracking branch 'origin/jaewon/fe-970-b8-unify-the-two-billingop-pollers-fold-pollcancelstatus' into jaewon/fe-991-e2e-integration-pass-for-billing-rework-v1-team-workspaces 2026-06-11 22:36:40 +09:00
dante01yoon
21998649f2 Merge remote-tracking branch 'origin/jaewon/fe-932-refresh-workspace-billing-status-after-completed-top-up' into jaewon/fe-991-e2e-integration-pass-for-billing-rework-v1-team-workspaces
# Conflicts:
#	src/platform/workspace/components/TopUpCreditsDialogContentWorkspace.vue
2026-06-11 22:36:23 +09:00
dante01yoon
a2b84ec170 Merge remote-tracking branch 'origin/jaewon/fe-769-updates-to-misc-ux' into jaewon/fe-991-e2e-integration-pass-for-billing-rework-v1-team-workspaces 2026-06-11 22:35:15 +09:00
dante01yoon
edfe2e690d Merge remote-tracking branch 'origin/jaewon/fe-934-unified-pricing-table' into jaewon/fe-991-e2e-integration-pass-for-billing-rework-v1-team-workspaces 2026-06-11 22:35:00 +09:00
dante01yoon
ac3e816549 Merge remote-tracking branch 'origin/jaewon/fe-770-promote-demote-workspace-members-owners-settings-members' into jaewon/fe-991-e2e-integration-pass-for-billing-rework-v1-team-workspaces 2026-06-11 22:34:55 +09:00
dante01yoon
c06bb29020 Merge remote-tracking branch 'origin/jaewon/fe-768-members-invite-ui' into jaewon/fe-991-e2e-integration-pass-for-billing-rework-v1-team-workspaces 2026-06-11 22:34:49 +09:00
dante01yoon
e90b2618d2 Merge remote-tracking branch 'origin/jaewon/fe-768-plan-credits-panel' into jaewon/fe-991-e2e-integration-pass-for-billing-rework-v1-team-workspaces 2026-06-11 22:34:45 +09:00
dante01yoon
85eb4ea741 Merge remote-tracking branch 'origin/jaewon/fe-964-build-unified-credits-tile-settings-plan-credits-on-the' into jaewon/fe-991-e2e-integration-pass-for-billing-rework-v1-team-workspaces 2026-06-11 22:34:39 +09:00
dante01yoon
2ca2360216 Merge remote-tracking branch 'origin/jaewon/fe-904-b2-complete-the-billing-facade-resubscribe-topup-status' into jaewon/fe-933-b3-repoint-direct-bypass-billing-consumers-to-the-facade
# Conflicts:
#	src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.test.ts
2026-06-11 18:34:38 +09:00
dante01yoon
4b7a51ec29 test(workspace): e2e for member role change user stories (FE-770)
Covers the Members-tab role flows against a fully mocked cloud boot
(FE-964 creditsTile pattern): creator/self rows hide the row menu while
member rows expose Change role + Remove member; promote shows the
Make-owner dialog (Figma copy), PATCHes the proposed role contract,
flips the Role column and re-sorts under the creator; the promoted
owner demotes back through the Demote dialog; same-role picks and
Cancel are no-ops; a failed PATCH keeps the dialog open with an error
toast. Reka submenus don't open on Playwright's synthetic hover, so the
subtrigger is driven with ArrowRight.
2026-06-11 17:31:38 +09:00
dante01yoon
8fa27a7732 feat(billing): downgrade-to-personal member-removal confirm flow (FE-977) 2026-06-11 17:28:23 +09:00
dante01yoon
ffc852137c refactor(billing): unify cancel-status polling into billingOperationStore (B8 / FE-970) 2026-06-11 17:27:43 +09:00
dante01yoon
51a064bba2 fix(billing): refresh workspace billing status after completed top-up (FE-932) 2026-06-11 17:20:46 +09:00
dante01yoon
99d817dfc5 refactor(billing): remove narration comments per review (FE-904) 2026-06-11 17:16:33 +09:00
Alexander Brown
b75ebde746 Merge branch 'main' into jaewon/fe-904-b2-complete-the-billing-facade-resubscribe-topup-status 2026-06-11 00:21:19 -07:00
dante01yoon
4d414ca2d3 fix(billing): route subscription/sign-in/credit preconditions to modal, out of error panel (FE-878) 2026-06-11 14:10:30 +09:00
Dante
391f9e464a test(billing): e2e regression for facade-driven billing surfaces (FE-933) (#12760)
## What
E2E regression coverage for the FE-933 (B3) facade repoints — closes the
browser_tests gap flagged on #12643.

> **Stacked on #12643** (base = the FE-933 branch). Retarget after it
merges.

## Coverage
`browser_tests/tests/billingFacadeConsumers.spec.ts` (`@cloud` project)
boots the cloud app against fully mocked endpoints (same raw-page
pattern as `creditsTile.spec.ts`):

- **Avatar popover (PRO, personal)** — tier badge "Pro", formatted
balance `12,660`, add-credits action all render from `useBillingContext`
→ legacy `/customers/*` adapter.
- **Free-tier dialog renewal date (T5)** — boots with
`subscription_required` + `team_workspaces_enabled` (production shape)
and an inactive FREE user; the subscription extension's
`requireActiveSubscription()` opens the free-tier dialog at boot and the
"Your credits refresh on Feb 20, 2099." line renders from the facade's
raw ISO `renewalDate`.

Mocks are typed (`CloudSubscriptionStatusResponse`, `RemoteConfig`);
renewal date uses 10:00Z so the en-US calendar date is timezone-stable
in CI.

## Verification
Local run against a real `pnpm build:cloud` dist served statically:
```
pnpm exec playwright test --project=cloud billingFacadeConsumers
  2 passed (3.0s)
```
typecheck / eslint / oxfmt clean.
2026-06-11 14:02:01 +09:00
dante01yoon
93d6c5c68d fix(workspace): drop ghost icon slot for icon-less dropdown items (FE-770)
DropdownItem always rendered a size-5 <i> even when the item had no
icon, indenting plain items relative to submenu triggers. Render the
slot only when the item declares an icon key, so icon-bearing menus
(which set the key on every item, sometimes empty for alignment) keep
their layout while icon-less menus get flush-left labels.
2026-06-11 14:00:41 +09:00
dante01yoon
a7b284c73e feat(billing): role-aware run-lock for cancelled/inactive team plans (FE-978) 2026-06-11 13:55:00 +09:00
dante01yoon
5a7ff04b01 fix(workspace): raise DropdownMenu above modal z-index sequence (FE-770)
The Reka popper wrapper copies the content's computed z-index, and the
static z-1700 lost to dialogs registered in the @primeuix modal ZIndex
sequence (1701+), hiding the member row menu behind Settings. Use the
same z-3000 as SelectContent.
2026-06-11 13:48:58 +09:00
dante01yoon
992da60ba7 feat(billing): add DES-247 empty states to the CreditsTile (FE-964) 2026-06-11 12:14:14 +09:00
dante01yoon
2ccb689c87 feat(workspace): promote/demote members via Change role menu (FE-770)
Per-member Change role submenu (Owner/Member, current role checked) and
Make-owner / Demote-to-member confirm dialogs in Settings > Members,
per Figma 2993-15512. Original owner (earliest joined; FE inference
until BE exposes a flag) is pinned to the top of the list and shows no
row actions. Wires PATCH /api/workspace/members/:userId {role} -- the
proposed contract tracked as the BE blocker on FE-770.
2026-06-11 10:53:07 +09:00
dante01yoon
2174f5b348 feat(workspace): match create-workspace dialog surface to DES-246
512px dialog width, muted name label, filled 40px text input per the
Create Workspace / Default frame (node 2797-724229)
2026-06-11 09:58:08 +09:00
dante01yoon
b80768fe90 test(workspace): cover switcher placement, badge removal, DES-246 copy 2026-06-11 09:58:08 +09:00
dante01yoon
d3f075938d feat(workspace): align switcher popover and copy to DES-246 (FE-769)
- Render the workspace switcher panel to the left of the profile menu
  instead of on top of it (PrimeVue nested Popover -> inline panel)
- Drop the tier badge on team workspace rows (single plan now);
  personal workspace keeps its badge
- Update create-workspace dialog copy and switcher create label to the
  DES-246 wording
2026-06-11 09:58:08 +09:00
dante01yoon
6898024cc9 fix(workspace): align Members header actions and single-member gating to DES-186 (FE-768)
- Move Search/Invite/workspace menu into the members card header row
  (tab row right side is empty per design); extract WorkspaceMenuButton
- Hide search, Active/Pending tabs, role column, and the Members tab
  count when the owner is the only member
- Contact us footer uses the textonly button variant for hover feedback
2026-06-10 23:49:27 +09:00
dante01yoon
920e43aabd test(billing): storybook stories for checkout confirm/success steps (FE-934) 2026-06-10 23:30:59 +09:00
dante01yoon
6f92f3835b feat(billing): align workspace Plan & Credits panel to DES-186 (FE-768)
- Subtitle states per design: 'Renews on {date}' / 'Ends on {date}';
  'scheduled' falls back to Active (facade lacks target-plan fields)
- Team header: 'Team' + seat-aware workspace total from plans
  (per-member tier price kept as plans-unresolved fallback)
- CTAs: Manage billing / Change plan (team) vs Upgrade plan (personal)
- Team perks per design ('everything in Pro, plus:' + 4 items)
- Personal free variant: Free $0 header + Subscribe + max-runtime perk
- Footer help bar extracted to SubscriptionFooterLinks, shared with
  the legacy panel
2026-06-10 21:24:51 +09:00
dante01yoon
68a548eb9b feat(billing): add 'You're all set' success step to unified checkout (FE-934) 2026-06-10 21:00:33 +09:00
dante01yoon
250a617d6c feat(billing): align confirm screens to DES-197 — drop /member, comfy credits icon, plan-specific CTAs (FE-934) 2026-06-10 20:29:18 +09:00
dante01yoon
605b685184 feat(workspace): redesign Members invite UI to DES-186 (FE-768)
- Invite dialog: comma/Enter-separated email chips (TagsInput), batch
  createInvite via allSettled, partial-failure recovery, Invited state
- Pending invites: overflow menu with Resend invite (create-first
  resendInvite) / Cancel invite; drop FE-built invite links
- Explicit isOnTeamPlan gating (useTeamPlan, maxSeats>1 proxy until
  BE-1254) replaces isSingleSeatPlan branching; personal-state banner
  'Upgrade to Team' + 'Need more members? Contact us' footer
2026-06-10 20:14:34 +09:00
dante01yoon
26be182ff3 test(billing): enable workspaces flag via remote config in cloud e2e
The cloud CI project runs a production build, which ignores the dev-only
ff: localStorage override — the Workspace settings panel never appeared
and the spec timed out on every run. Resolve the flag the way production
does: mock /api/features (the remote-config source) with
team_workspaces_enabled instead.
2026-06-10 19:02:39 +09:00
dante01yoon
261406de3f fix(billing): repoint PricingTable subscription state to the facade
Current-plan highlight and upgrade-vs-subscribe branching read the
legacy useSubscription store. Take isActiveSubscription/isFreeTier/tier
from useBillingContext and derive isYearlySubscription from the facade
subscription duration, matching PricingTableWorkspace. The billing
portal flow (accessBillingPortal deep-links + proration) stays as is —
facade manageSubscription is not behavior-identical.

Part of B3 (FE-933).
2026-06-10 18:34:24 +09:00
dante01yoon
b37884c465 fix(billing): repoint CurrentUserPopoverLegacy to the billing facade
The personal-workspace avatar popover read tier/balance state from the
legacy useSubscription store and authStore directly. Source it all from
useBillingContext (tier, subscription, balance, isLoading, fetchStatus,
fetchBalance) and derive the tier badge via useWorkspaceTierLabel
instead of duplicating the tier-name mapping.

Part of B3 (FE-933).
2026-06-10 18:34:16 +09:00
dante01yoon
fb6e84783f fix(billing): repoint FreeTierDialogContent renewal date to the facade
The next-refresh line read formattedRenewalDate from the legacy
useSubscription store, which is empty for team workspaces, so the date
silently disappeared for team users (T5). Read the facade's raw ISO
renewalDate and format at the display site with the same en-US format.

Part of B3 (FE-933).
2026-06-10 18:33:55 +09:00
dante01yoon
9e48c13d98 fix(billing): repoint balance refresh to the facade fetchBalance
useSubscriptionActions.handleRefresh and the SettingDialog credits-nav
refresh called the legacy authActions.fetchBalance (/customers only),
so team-workspace balances were never refreshed. Route both through
useBillingContext().fetchBalance, which is workspace-aware.

Part of B3 (FE-933).
2026-06-10 18:33:40 +09:00
dante01yoon
5924dd92b6 fix(billing): repoint SubscribeButton + PostHog tier to the billing facade
The subscribe-clicked telemetry `current_tier` (SubscribeButton) and the
PostHog `subscription_tier` person property read the legacy useSubscription
store, which is empty/stale for team workspaces — so telemetry and analytics
record the wrong tier for team users today. Source the tier from
useBillingContext().tier (added in B2) so it is workspace-aware.

B3 of the billing convergence plan (FE-933). Stacked on FE-904 (B2).
2026-06-10 18:13:57 +09:00
dante01yoon
b695686504 feat(billing): add DES-247 responsive variants to CreditsTile (FE-964)
Container-query the tile: narrow widths drop the used/remaining labels
and breakdown subtitle, stack the additional-credits value, and compact
the monthly summary numbers (105K left of 200K). Align the additional
credits tooltip copy with the updated design.
2026-06-10 18:08:02 +09:00
dante01yoon
e4ff4becb7 feat(billing): finalize CreditsTile layout to DES-247 (FE-964)
Match the finalized DES-247 credits tile: single monthly-usage bar (gold,
fills with the consumed portion) with a Monthly / Refills header and
"{used} used" + "{remaining} left of {total}" labels, a divider, and a single
Additional-credits row with a credit icon and "Used after monthly runs out"
subtitle. Drops the two-segment bar and colored dots; updates i18n + the
unit/e2e assertions accordingly.
2026-06-10 13:10:18 +09:00
dante01yoon
e12d3d7f40 test(billing): e2e for the Plan & Credits CreditsTile (FE-964)
Drives a raw page with mocked Firebase auth + stubbed boot endpoints so the
cloud app initializes without a backend, then asserts the DES-247 tile renders
its total/progress/monthly+additional breakdown/add-credits in Settings ▸
Workspace ▸ Plan & Credits. Bypasses the comfyPage fixture (which requires the
OSS devtools backend).
2026-06-10 13:10:18 +09:00
dante01yoon
f737d9b749 feat(billing): unify credits into a facade-driven CreditsTile (FE-964)
Consolidate the divergent credits surfaces into a single CreditsTile that
always reads the useBillingContext facade, matching DES-247:

- New CreditsTile.vue: total + remaining, monthly/additional breakdown with
  a stacked progress bar, refresh, and permission-gated add-credits. Owns the
  post-checkout (focus / pending_topup) balance refresh.
- Extract the duplicated credits card out of SubscriptionPanelContentWorkspace
  and SubscriptionPanelContentLegacy onto the shared tile.
- Replace LegacyCreditsPanel (authStore.balance) with CreditsPanel.vue routed
  through the tile; repoint useSettingUI; delete LegacyCreditsPanel.
- creditsProgress helper + numeric getters on useSubscriptionCredits.
- i18n keys for the unified tile labels.
2026-06-10 13:10:18 +09:00
dante01yoon
4b3b6a841c test(billing): cover facade resubscribe/topup per review
- useWorkspaceBilling: resubscribe refreshes status+balance and surfaces
  errors; topup stays a pass-through (no double-fetch) and surfaces errors
- useBillingContext: legacy topup converts cents to whole dollars
- types: trim convenience-accessor JSDoc, note whole-dollar-cents contract
2026-06-10 13:08:20 +09:00
dante01yoon
ff628e49e3 fix(billing): expose raw ISO dates from the legacy billing adapter
The workspace adapter feeds subscription.renewalDate/endDate straight from
the API (ISO 8601), but the legacy adapter passed pre-formatted display
strings, so facade consumers like CreditsTile got different shapes per
adapter. Source both from subscriptionStatus and leave formatting to the
display site.
2026-06-10 13:03:58 +09:00
dante01yoon
8b5e7d4ec5 fix(billing): match DES-197 dialog surfaces + personal price unit
- pricing dialog shell uses secondary-background (elevated) so the
  base-background cover panel + cards read as the darker well per DES-197;
  drop the translucent base/60 + backdrop-blur
- cover panel gets solid bg-base-background
- personal tier price unit "USD / mo / member" -> "USD / mo" (matches DES-197
  and the team slider; fixes awkward wrap on mobile)
2026-06-10 01:12:44 +09:00
dante01yoon
0c6ca27823 feat(billing): close DES-197 design gaps in UnifiedPricingTable (FE-934)
- personal cards: replace uniform attribute matrix with progressive
  "What's included:" / "Everything in {prev}, plus:" bullets + credit block
  (monthly credits + "Generates ~N 5s videos*"); drop dead per-card popover
- unify monthly-credits / video-estimate / "Everything in {plan}" into shared
  subscription.* i18n keys reused by both personal and team
- team Details checks use foreground color, "Invite team members" copy
- billing-cycle badge "-20%" -> "Save 20%"
- Creator CTA to inverted (white) to match DES-197 and legacy PricingTable
2026-06-09 23:19:27 +09:00
Dante
7c4ffb3485 Merge branch 'jaewon/fe-935-team-plan-credit-slider' into jaewon/fe-934-unified-pricing-table 2026-06-09 11:21:35 +09:00
dante01yoon
ff4a690496 fix(billing): clamp CreditSlider fallback index, add disabled-state test
Guard selectedIndex so a shorter-than-expected backend `stops` array (or an
out-of-bounds `defaultStopIndex`) can't make `current` undefined and crash the
price computeds; floor `lastIndex` at 0 so the slider max is never negative.
`stops` is documented as required non-empty. Add a unit test asserting no
update:modelValue / change events fire when the slider is disabled.

Addresses CodeRabbit review on #12644 (undefined-`current` runtime path;
disabled-state test coverage).
2026-06-08 18:27:17 +09:00
dante01yoon
2fd8f685ed feat(billing): make CreditSlider stops prop-driven (stops + defaultStopIndex)
- Accept `stops` + `defaultStopIndex` props (default to the hardcoded DES-197 set) so the slider can be fed from GET /api/billing/plans (BE-1254) without code changes; selectedIndex falls back to defaultStopIndex
- Add a prop-driven unit test and a "Backend-driven stops" story that maps the team_credit_stops API shape into the props
2026-06-05 23:59:46 +09:00
dante01yoon
72507cf225 fix(billing): keep CreditSlider price on one row, wrap the Save badge when narrow
- The price (amount + struck + "USD / mo") is now an unbreakable unit (shrink-0 + whitespace-nowrap); the Save badge wraps to its own line on a narrow card instead of breaking "USD / mo" mid-word at the widest stop
- Story previews at the real DES-197 width: 512px card column with 32px padding -> 448px content
2026-06-05 23:57:19 +09:00
dante01yoon
6cadc4eb5a feat(billing): align CreditSlider price + Save badge with DES-197, animate count
- Save badge -> outlined primary pill, right-aligned (border-2 border-primary-background, text-primary-background, rounded-full, text-sm bold); bump price to 32px, per Figma 2983:29834
- Count the price up/down between stops via @vueuse useTransition (easeOutCubic, 350ms); honor prefers-reduced-motion
- Derive billed-yearly from the displayed monthly so it stays exactly 12x the shown price even mid-count
2026-06-05 23:56:21 +09:00
dante01yoon
a013d4b123 test(storybook): mock useBillingContext so UnifiedPricingTable renders
The real facade pulls in Firebase auth via the legacy adapter and crashes in
Storybook (TypeError: setPersistence). Alias it to a static stub so the new
table renders against TIER_PRICING / DES-197 fallbacks.
2026-06-05 18:06:49 +09:00
dante01yoon
f25ef177b8 feat(billing): add UnifiedPricingTable stories + initialPlanMode prop
Adds Personal / TeamPlan stories so the new table renders on the deployed
Storybook preview. initialPlanMode (default personal) lets the story show the
team view without the server flag (the toggle itself is flag-gated).
2026-06-05 17:59:38 +09:00
GitHub Action
dfd08cd13d [automated] Apply ESLint and Oxfmt fixes 2026-06-05 08:00:56 +00:00
dante01yoon
e1bad829e7 feat(billing): add UnifiedPricingTable + dispatch behind teamWorkspacesEnabled
B4 (FE-934): a single pricing table for the Jun-5 model — one workspace with a
personal/team PLAN toggle (Gamma-style), per DES-197. New, flag-gated component
that will replace PricingTable.vue + PricingTableWorkspace.vue at cutover
(strangler).

- UnifiedPricingTable.vue: plan toggle, personal tier cards (facade plans with
  TIER_PRICING fallback), team column hosting CreditSlider, Enterprise card.
  Reuses useBillingContext; emits subscribe/resubscribe (personal) +
  subscribeTeam (team).
- SubscriptionRequiredDialogContentUnified.vue: host wiring the table to
  useSubscriptionCheckout for personal checkout; team checkout stubbed (toast)
  pending the BE slider contract (doc Open Q#2).
- showPricingTable: render the unified host when teamWorkspacesEnabled; the
  legacy PricingTable stays for the flag-off build.
- i18n: subscription.planScope / teamPlan / enterprise keys.

Stacked on the FE-935 CreditSlider branch. Personal checkout fully wired; team
checkout + live /api/billing/plans discount data deferred to the BE contract.
vue-tsc + oxlint clean; dispatcher tests pass (11).
2026-06-05 16:56:36 +09:00
dante01yoon
71d1ece521 test(billing): mock resubscribe via useBillingContext facade
The billing facade refactor moved resubscribe() onto useBillingContext,
but useSubscriptionCheckout's test still stubbed it on workspaceApi, which
the composable no longer calls. resubscribe was undefined at runtime, so
handleResubscribe threw "resubscribe is not a function" -- failing the
"emits close on success" and "shows error toast on failure" cases.

Wire mockResubscribe into the useBillingContext mock where the composable
reads it. The workspaceApi mock stays to shield the test from the real
@/scripts/api -> app.ts import chain.
2026-06-05 15:44:50 +09:00
dante01yoon
39854ae51b feat(billing): align CreditSlider with DES-197 (discount, credit ticks, yearly total)
Bring the team-plan slider up to the settled DES-197 / Slack design:
- discounted monthly price + struck pre-discount price + Save% badge
- '$N billed yearly' annual total
- stop labels now show CREDITS (42.2K..527.5K) with a credit icon and a
  gold highlight on the selected stop (was raw USD amounts)
- add threshold-based yearly discounts to TEAM_PLAN_CREDIT_STOPS
  (0/5/10/15/20%, $700=10% per design; others per the agreed sequence, TBD)
- USD / mo unit, drop the separate credits readout (credits live on the ticks)
2026-06-05 13:19:24 +09:00
dante01yoon
a0f2445a01 fix(billing): i18n CreditSlider labels to satisfy no-raw-text lint
lint-and-format failed on @intlify/vue-i18n/no-raw-text for the hardcoded
'/ month' and 'credits' template strings. Route both through vue-i18n
(subscription.perMonth, credits.credits — same key UserCredit.vue uses) and
inject an i18n instance in the component test. Also normalize tailwind class
order flagged by better-tailwindcss.
2026-06-04 19:30:31 +09:00
dante01yoon
30e0e0b031 feat(billing): add team-plan CreditSlider component (5 fixed stops)
Standalone presentational slider for the team-plan credit subscription. It
snaps to the 5 fixed DES-197 stops (200/400/700/1400/2500 USD) by driving the
shared reka-ui Slider in index space (min=0, max=4, step=1) — the user can
never land on a value in between. v-model carries the selected USD value; a
`change` event also emits { index, usd, credits }.

Thresholds live in a typed constant (teamPlanCreditStops.ts) hardcoded per
Figma DES-197 until the backend slider contract lands; a test guards that the
credit figures stay equal to usdToCredits(usd).

B4 standalone slice (FE-935). Not yet wired into PricingTableWorkspace
(deferred to FE-934, blocked on the BE slider contract).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 18:18:19 +09:00
Dante
5efb45b407 Merge branch 'main' into jaewon/fe-904-b2-complete-the-billing-facade-resubscribe-topup-status 2026-06-04 15:06:41 +09:00
dante01yoon
99b5107275 fix(billing): make workspace topup a pass-through to avoid double balance fetch
The completed-topup balance refresh is already owned by the caller
(TopUpCreditsDialogContentWorkspace); refreshing inside the facade too caused
a redundant second fetch. Keep topup a thin pass-through that returns the
CreateTopupResponse so the caller orchestrates completed/pending.
2026-06-04 14:14:04 +09:00
dante01yoon
d46846410a refactor(billing): complete billing facade with resubscribe/topup + status fields
Add resubscribe(), topup(amountCents) and billingStatus/subscriptionStatus/
tier/renewalDate to the shared BillingContext so components stop bypassing
useBillingContext with raw workspaceApi calls (B2 of the billing-divergence
convergence plan, FE-904).

- types.ts: extend BillingActions + BillingState; export BillingStatus,
  BillingSubscriptionStatus, CreateTopupResponse from workspaceApi
- useWorkspaceBilling: real wiring (workspaceApi.resubscribe/createTopup,
  surface statusData fields)
- useLegacyBilling: legacy equivalents (resubscribe = fresh checkout;
  topup converts cents -> dollars via purchaseCredits; billing_status = null)
- useBillingContext: proxy the new members
- migrate orphaned callers (SubscriptionPanelContentWorkspace,
  TopUpCreditsDialogContentWorkspace, useSubscriptionCheckout) onto the facade

Facade standardizes topup on cents; the legacy adapter converts.
2026-06-03 20:58:22 +09:00
118 changed files with 9095 additions and 2628 deletions

View File

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

View File

@@ -0,0 +1,161 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
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`, 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'
const jsonRoute = (body: unknown) => ({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
})
async function mockCloudBoot(
page: Page,
subscriptionStatus: CloudSubscriptionStatusResponse,
remoteConfig: RemoteConfig = { team_workspaces_enabled: false }
) {
await page.route('**/api/features', (r) => r.fulfill(jsonRoute(remoteConfig)))
await page.route('**/api/system_stats', (r) =>
r.fulfill(jsonRoute(mockSystemStats))
)
await page.route('**/api/users', (r) =>
r.fulfill(
jsonRoute({
storage: 'server',
migrated: true,
users: { 'test-user-e2e': 'E2E Test User' }
})
)
)
await page.route('**/api/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([])))
// 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({
workspaces: [
{
id: 'ws-personal',
name: 'Personal Workspace',
type: 'personal',
role: 'owner'
}
]
})
)
)
await page.route('**/customers/cloud-subscription-status', (r) =>
r.fulfill(jsonRoute(subscriptionStatus))
)
await page.route('**/customers/balance', (r) =>
r.fulfill(
jsonRoute({
amount_micros: 6000, // -> 12,660 credits
currency: 'usd',
effective_balance_micros: 6000
})
)
)
}
async function bootApp(page: Page) {
const auth = new CloudAuthHelper(page)
await auth.mockAuth()
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
})
}
test.describe('Billing facade consumers (FE-933)', { tag: '@cloud' }, () => {
test('avatar popover renders tier badge and balance from the facade', async ({
page
}) => {
test.setTimeout(60_000)
await mockCloudBoot(page, {
is_active: true,
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY',
renewal_date: '2099-02-20T10:00:00Z',
end_date: null
})
await bootApp(page)
await page.getByRole('button', { name: 'Current user' }).click()
const popover = page.locator('.current-user-popover')
await expect(popover).toBeVisible()
await expect(popover.getByText('Pro', { exact: true })).toBeVisible()
await expect(popover.getByText('12,660')).toBeVisible()
await expect(popover.getByTestId('add-credits-button')).toBeVisible()
})
test('free-tier dialog shows the renewal date from the facade', async ({
page
}) => {
test.setTimeout(60_000)
// Subscription gating is config-driven: with subscription_required on,
// the cloud subscription extension calls requireActiveSubscription() at
// boot, which opens the free-tier dialog for an inactive FREE user.
// (refreshRemoteConfig overwrites window.__CONFIG__ from /api/features,
// so the flag must come from the features mock, not an init script.)
// The free-tier dialog branch additionally requires an active personal
// workspace, so this boots with team workspaces enabled (production
// shape) — the facade still routes personal through `/customers/*`.
await mockCloudBoot(
page,
{
is_active: false,
subscription_tier: 'FREE',
subscription_duration: 'MONTHLY',
// 10:00Z keeps the en-US calendar date stable across CI timezones.
renewal_date: '2099-02-20T10:00:00Z',
end_date: null
},
{ team_workspaces_enabled: true, subscription_required: true }
)
await bootApp(page)
// T5: the dialog must source the date from facade renewalDate — when this
// line read the legacy store it silently vanished for team users.
await expect(
page.getByText('Your credits refresh on Feb 20, 2099.')
).toBeVisible()
})
})

View File

@@ -0,0 +1,231 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
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 a single personal
* workspace that routes the billing facade through the legacy `/customers/*`
* endpoints (mocked with an active Pro subscription). 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)
})
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' }
})
)
)
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([])))
// Workspace list — a single personal workspace keeps the billing facade on
// the legacy `/customers/*` path.
await page.route('**/api/workspaces', (r) =>
r.fulfill(
jsonRoute({
workspaces: [
{
id: 'ws-personal',
name: 'Personal Workspace',
type: 'personal',
role: 'owner'
}
]
})
)
)
// Legacy billing (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-20T00:00:00Z',
end_date: null
})
)
)
await page.route('**/customers/balance', (r) =>
r.fulfill(
jsonRoute({
amount_micros: 6000, // -> 12,660 total credits
currency: 'usd',
effective_balance_micros: 6000,
cloud_credit_balance_micros: 5000, // -> 10,550 monthly remaining
prepaid_balance_micros: 1000 // -> 2,110 additional
})
)
)
}
async function mockBalance(
page: Page,
balance: { amount: number; monthly: number; prepaid: number }
) {
await page.unroute('**/customers/balance')
await page.route('**/customers/balance', (r) =>
r.fulfill(
jsonRoute({
amount_micros: balance.amount,
currency: 'usd',
effective_balance_micros: balance.amount,
cloud_credit_balance_micros: balance.monthly,
prepaid_balance_micros: balance.prepaid
})
)
)
}
/** Boots the mocked cloud app and opens Settings ▸ Workspace ▸ Plan & Credits. */
async function openPlanAndCredits(page: Page) {
const auth = new CloudAuthHelper(page)
await auth.mockAuth()
// Pre-select the mock user to skip the user-select screen.
await page.addInitScript(() => {
localStorage.setItem('Comfy.userId', 'test-user-e2e')
})
await page.goto(APP_URL)
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
// Open Settings ▸ Workspace.
await page
.getByRole('button', { name: /^Settings/ })
.first()
.click()
const dialog = page.getByTestId('settings-dialog')
await expect(dialog).toBeVisible()
await dialog.locator('nav').getByRole('button', { name: 'Workspace' }).click()
return dialog.getByRole('main')
}
test.describe('Credits tile (Plan & Credits)', { tag: '@cloud' }, () => {
test('renders the unified tile with breakdown and add-credits', async ({
page
}) => {
test.setTimeout(60_000)
await mockCloudBoot(page)
const content = await openPlanAndCredits(page)
// Total + remaining suffix (Pro monthly allowance = 21,100; remaining
// 10,550 -> used 10,550).
await expect(content.getByText('Total credits')).toBeVisible()
await expect(content.getByText('12,660')).toBeVisible()
// Monthly usage bar header + used / left-of-total labels.
await expect(content.getByText('Monthly', { exact: true })).toBeVisible()
await expect(content.getByText(/Refills Feb/)).toBeVisible()
await expect(content.getByText('10,550 used')).toBeVisible()
await expect(content.getByText('10,550 left of 21,100')).toBeVisible()
// Additional credits row + subtitle.
await expect(content.getByText('Additional credits')).toBeVisible()
await expect(content.getByText('2,110')).toBeVisible()
await expect(content.getByText('Used after monthly runs out')).toBeVisible()
// Permission-gated add-credits action (personal owner can top up).
await expect(
content.getByRole('button', { name: 'Add credits' })
).toBeVisible()
// Narrow container (DES-247 responsive variants): drop the used/remaining
// labels and the breakdown subtitle, compact the monthly summary numbers.
await page.setViewportSize({ width: 360, height: 800 })
await expect(content.getByText('10,550 used')).toBeHidden()
await expect(content.getByText('remaining', { exact: true })).toBeHidden()
await expect(content.getByText('Used after monthly runs out')).toBeHidden()
await expect(content.getByText('10,550 left of 21,100')).toBeHidden()
await expect(content.getByText('11K left of 21K')).toBeVisible()
})
test('renders the depleted-credit empty states', async ({ page }) => {
test.setTimeout(60_000)
await mockCloudBoot(page)
// Monthly allowance fully spent; additional credits keep generation going.
await mockBalance(page, { amount: 1000, monthly: 0, prepaid: 1000 })
const content = await openPlanAndCredits(page)
// 0-monthly state: depletion notice + IN USE badge on additional credits.
await expect(
content.getByText('Monthly credits are used up. Refills Feb 20')
).toBeVisible()
await expect(
content.getByText("You're now spending additional credits.")
).toBeVisible()
await expect(content.getByText('In use')).toBeVisible()
await expect(content.getByText('0 left of 21,100')).toBeVisible()
// Drain the remaining additional credits and refresh the tile: the
// out-of-credits notice takes over and the badge drops.
await mockBalance(page, { amount: 0, monthly: 0, prepaid: 0 })
await content.getByRole('button', { name: 'Refresh credits' }).click()
await expect(
content.getByText("You're out of credits. Credits refill Feb 20")
).toBeVisible()
await expect(
content.getByText('Add more credits to continue generating.')
).toBeVisible()
await expect(content.getByText('In use')).toBeHidden()
await expect(
content.getByRole('button', { name: 'Add credits' })
).toBeVisible()
})
})

View File

@@ -0,0 +1,411 @@
import { expect } from '@playwright/test'
import type { Page, Route } from '@playwright/test'
import type {
BillingStatusResponse,
Member,
Plan,
WorkspaceWithRole
} from '@/platform/workspace/api/workspaceApi'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
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.
/**
* Member role change (Settings ▸ Workspace ▸ Members) — FE-770 / 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'
const jsonRoute = (body: unknown) => ({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
})
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
}
// Matches the CloudAuthHelper mock user so this row counts as "(You)".
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
}
const 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
}
const 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
}
interface RoleChangeRequest {
url: string
role: string
}
interface MemberMockState {
members: Member[]
patches: RoleChangeRequest[]
}
async function mockCloudBoot(page: Page): Promise<MemberMockState> {
const state: MemberMockState = {
members: [CREATOR, VIEWER, JANE, JOHN].map((m) => ({ ...m })),
patches: []
}
// `/api/features` is the remote-config source: production builds resolve
// the workspaces flag from it (the `ff:` localStorage override is dev-only).
await page.route('**/api/features', (r) =>
r.fulfill(
jsonRoute({ team_workspaces_enabled: true } satisfies RemoteConfig)
)
)
await page.route('**/api/system_stats', (r) =>
r.fulfill(jsonRoute(mockSystemStats))
)
await page.route('**/api/users', (r) =>
r.fulfill(
jsonRoute({
storage: 'server',
migrated: true,
users: { 'test-user-e2e': 'E2E Test User' }
})
)
)
await page.route('**/api/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('**/api/auth/token', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
const teamWorkspace: 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'
}
await page.route('**/api/workspaces', (r) =>
r.fulfill(jsonRoute({ workspaces: [teamWorkspace] }))
)
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
// The store applies the returned Member to local state; echo the full
// updated row so role/id survive the round trip.
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: [] }))
)
const billingStatus: 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'
}
await page.route('**/api/billing/status', (r) =>
r.fulfill(jsonRoute(billingStatus))
)
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
})
)
)
// `max_seats > 1` on the current plan is what flips `isOnTeamPlan`,
// which gates the whole role-management UI.
const proPlan: 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
}
}
await page.route('**/api/billing/plans', (r) =>
r.fulfill(jsonRoute({ current_plan_slug: 'pro-monthly', plans: [proPlan] }))
)
return state
}
async function openMembersTab(page: Page) {
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: ReturnType<Page['locator']>, email: string) {
return content
.locator('div.grid')
.filter({ has: content.page().getByText(email, { exact: true }) })
}
// 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('menuitem', { name: 'Owner', exact: true })
).toBeVisible()
}
async function setupCloudPage(page: Page): Promise<MemberMockState> {
const state = await mockCloudBoot(page)
const auth = new CloudAuthHelper(page)
await auth.mockAuth()
await page.addInitScript(() => {
localStorage.setItem('Comfy.userId', 'test-user-e2e')
localStorage.setItem('Comfy.Workspace.LastWorkspaceId', 'ws-team')
})
return state
}
test.describe('Member role change (Members tab)', { tag: '@cloud' }, () => {
test('row menus respect creator and self guards', async ({ page }) => {
test.setTimeout(60_000)
await setupCloudPage(page)
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(
memberRow(content, 'john@test.comfy.org').getByRole('button', {
name: 'More Options'
})
).toBeVisible()
await expect(
memberRow(content, 'jane@test.comfy.org').getByRole('button', {
name: 'More Options'
})
).toBeVisible()
await expect(
memberRow(content, 'liz@test.comfy.org').getByRole('button', {
name: 'More Options'
})
).toHaveCount(0)
await expect(
memberRow(content, 'e2e@test.comfy.org').getByRole('button', {
name: 'More Options'
})
).toHaveCount(0)
// US1/US12 — the row menu exposes Change role and the FE-768 remove flow.
await memberRow(content, 'jane@test.comfy.org')
.getByRole('button', { name: 'More Options' })
.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('promote and demote round trip updates the Role column', async ({
page
}) => {
test.setTimeout(90_000)
const state = await setupCloudPage(page)
const content = await openMembersTab(page)
const emails = content.getByText(/@test\.comfy\.org/)
await expect(emails).toHaveText([
'liz@test.comfy.org',
'e2e@test.comfy.org',
'john@test.comfy.org',
'jane@test.comfy.org'
])
const janeRow = memberRow(content, 'jane@test.comfy.org')
await janeRow.getByRole('button', { name: 'More Options' }).click()
await openChangeRoleSubmenu(page)
// US3 — picking the current role is a no-op.
await page.getByRole('menuitem', { name: 'Member', exact: true }).click()
await expect(page.getByRole('heading', { name: /an owner\?/ })).toHaveCount(
0
)
expect(state.patches).toHaveLength(0)
// US4 — promote dialog copy straight from Figma.
await janeRow.getByRole('button', { name: 'More Options' }).click()
await openChangeRoleSubmenu(page)
await page.getByRole('menuitem', { 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()
// US5 — cancel leaves the role untouched.
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)
// US6 — confirming PATCHes the proposed contract and re-sorts the row
// under the creator; the promoted owner keeps its row menu.
await janeRow.getByRole('button', { name: 'More Options' }).click()
await openChangeRoleSubmenu(page)
await page.getByRole('menuitem', { 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([
'liz@test.comfy.org',
'e2e@test.comfy.org',
'jane@test.comfy.org',
'john@test.comfy.org'
])
expect(state.patches).toEqual([
{
url: expect.stringContaining('/api/workspace/members/u-jane'),
role: 'owner'
}
])
// US7 — demote round trip from the promoted owner row.
await janeRow.getByRole('button', { name: 'More Options' }).click()
await openChangeRoleSubmenu(page)
await page.getByRole('menuitem', { 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).toHaveLength(2)
expect(state.patches[1].role).toBe('member')
})
test('failed role change keeps the dialog open with an error toast', async ({
page
}) => {
test.setTimeout(60_000)
await setupCloudPage(page)
// Override the member route so PATCH fails after boot succeeds.
await page.route('**/api/workspace/members/**', (route: Route) =>
route.request().method() === 'PATCH'
? route.fulfill({ status: 500, body: '{}' })
: route.fallback()
)
const content = await openMembersTab(page)
const janeRow = memberRow(content, 'jane@test.comfy.org')
await janeRow.getByRole('button', { name: 'More Options' }).click()
await openChangeRoleSubmenu(page)
await page.getByRole('menuitem', { name: 'Owner', exact: true }).click()
await page.getByRole('button', { name: 'Make owner' }).click()
// US10 — error toast, dialog stays open, role unchanged.
await expect(page.getByText('Failed to update role')).toBeVisible()
await expect(
page.getByRole('heading', { name: 'Make Jane an owner?' })
).toBeVisible()
await page.getByRole('button', { name: 'Cancel', exact: true }).click()
await expect(janeRow.getByText('Member', { exact: true })).toBeVisible()
})
})

View File

@@ -0,0 +1,243 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type {
Member,
WorkspaceWithRole
} from '@/platform/workspace/api/workspaceApi'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
/**
* The `?pricing=` deep link opens the pricing table on app load, gated to the
* original owner (canManageSubscriptionLifecycle). Drives a raw `page` so the
* cloud app boots against fully mocked endpoints, like the survey-gate spec.
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
// CloudAuthHelper.mockAuth() signs in as this email; the original-owner gate
// matches it against the members self-row.
const SELF_EMAIL = 'e2e@test.comfy.org'
function jsonRoute(body: unknown) {
return {
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
}
}
async function mockCloudBoot(page: Page) {
// `/api/features` is the remote-config source; enable team workspaces so the
// unified pricing table (and the lifecycle gate) are live.
await page.route('**/api/features', (r) =>
r.fulfill(
jsonRoute({ team_workspaces_enabled: true } satisfies RemoteConfig)
)
)
await page.route('**/api/system_stats', (r) =>
r.fulfill(jsonRoute(mockSystemStats))
)
await page.route('**/api/users', (r) =>
r.fulfill(
jsonRoute({
storage: 'server',
migrated: true,
users: { 'test-user-e2e': 'E2E Test User' }
})
)
)
await page.route('**/api/user', (r) =>
r.fulfill(jsonRoute({ status: 'active' }))
)
// Disable the experimental Asset API: with it on (cloud default) the
// unmocked asset endpoints 403 and workflow restore throws uncaught,
// aborting the GraphCanvas onMounted chain before the deep-link loader.
await page.route('**/api/settings', (r) =>
r.fulfill(jsonRoute({ 'Comfy.Assets.UseAssetAPI': false }))
)
await page.route('**/api/settings/**', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
// Queue/prompt status: a missing exec_info throws on boot and aborts the
// GraphCanvas onMounted chain before the deep-link loader runs.
await page.route('**/api/prompt', (r) =>
r.fulfill(jsonRoute({ exec_info: { queue_remaining: 0 } }))
)
await page.route('**/api/queue', (r) =>
r.fulfill(jsonRoute({ queue_running: [], queue_pending: [] }))
)
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
}
async function mockBilling(page: Page) {
// Minimal valid shapes so the billing facade resolves while the dialog mounts.
await page.route('**/api/billing/status', (r) =>
r.fulfill(
jsonRoute({
is_active: true,
has_funds: true,
subscription_status: 'active',
subscription_tier: 'pro',
subscription_duration: 'MONTHLY',
billing_status: 'paid'
})
)
)
await page.route('**/api/billing/balance', (r) =>
r.fulfill(jsonRoute({ amount_micros: 0, currency: 'usd' }))
)
await page.route('**/api/billing/plans', (r) =>
r.fulfill(jsonRoute({ plans: [] }))
)
await page.route('**/customers/cloud-subscription-status', (r) =>
r.fulfill(jsonRoute({ is_active: false }))
)
await page.route('**/customers/balance', (r) =>
r.fulfill(jsonRoute({ amount_micros: 0, currency: 'usd' }))
)
}
function workspace(
type: 'personal' | 'team',
role: 'owner' | 'member'
): WorkspaceWithRole {
return {
id: `ws-${type}`,
name: type === 'team' ? 'My Team' : 'Personal Workspace',
type,
role,
created_at: '2026-01-01T00:00:00Z',
joined_at: '2026-01-01T00:00:00Z'
}
}
async function mockWorkspace(
page: Page,
ws: WorkspaceWithRole,
members: Member[]
) {
await page.route('**/api/workspaces', async (route) => {
if (route.request().method() !== 'GET') return route.fallback()
await route.fulfill(jsonRoute({ workspaces: [ws] }))
})
await page.route('**/api/auth/token', (r) =>
r.fulfill(
jsonRoute({
token: 'mock-workspace-token',
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
workspace: { id: ws.id, name: ws.name, type: ws.type },
role: ws.role,
permissions: []
})
)
)
await page.route('**/api/workspace/members**', (r) =>
r.fulfill(
jsonRoute({
members,
pagination: { offset: 0, limit: 50, total: members.length }
})
)
)
}
async function bootCloud(page: Page) {
const auth = new CloudAuthHelper(page)
await auth.mockAuth()
// Pre-select the mock user to skip the user-select screen.
await page.addInitScript(() => {
localStorage.setItem('Comfy.userId', 'test-user-e2e')
})
}
const pricingHeading = (page: Page) =>
page.getByRole('heading', { name: 'Choose a Plan' })
function member(
overrides: Partial<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
}
}
test.describe('Pricing table deep link', { tag: '@cloud' }, () => {
test('opens the pricing table for a personal owner', async ({ page }) => {
test.setTimeout(60_000)
await mockCloudBoot(page)
await mockBilling(page)
await mockWorkspace(page, workspace('personal', 'owner'), [])
await bootCloud(page)
await page.goto(`${APP_URL}/?pricing=1`)
await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 })
await expect(page).not.toHaveURL(/[?&]pricing=/)
})
test('opens on the Team tab for ?pricing=team', async ({ page }) => {
test.setTimeout(60_000)
await mockCloudBoot(page)
await mockBilling(page)
await mockWorkspace(page, workspace('personal', 'owner'), [])
await bootCloud(page)
await page.goto(`${APP_URL}/?pricing=team`)
await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 })
await expect(
page.getByRole('button', { name: 'For Teams' })
).toHaveAttribute('aria-pressed', 'true')
})
test('opens for a team original owner', async ({ page }) => {
test.setTimeout(60_000)
await mockCloudBoot(page)
await mockBilling(page)
await mockWorkspace(page, workspace('team', 'owner'), [
member({ email: SELF_EMAIL, role: 'owner', is_original_owner: true })
])
await bootCloud(page)
await page.goto(`${APP_URL}/?pricing=1`)
await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 })
})
test('is a silent no-op for a team member', async ({ page }) => {
test.setTimeout(60_000)
await mockCloudBoot(page)
await mockBilling(page)
await mockWorkspace(page, workspace('team', 'member'), [
member({
email: 'creator@test.comfy.org',
role: 'owner',
is_original_owner: true
}),
member({ email: SELF_EMAIL, role: 'member' })
])
await bootCloud(page)
await page.goto(`${APP_URL}/?pricing=1`)
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
await expect(page).not.toHaveURL(/[?&]pricing=/)
await expect(pricingHeading(page)).toBeHidden()
})
})

View File

@@ -236,6 +236,8 @@
--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);
@@ -384,6 +386,8 @@
--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);
@@ -554,6 +558,8 @@
--color-secondary-background: var(--secondary-background);
--color-secondary-background-hover: var(--secondary-background-hover);
--color-secondary-background-selected: var(--secondary-background-selected);
--color-tertiary-background: var(--tertiary-background);
--color-tertiary-background-hover: var(--tertiary-background-hover);
--color-primary-background: var(--primary-background);
--color-primary-background-hover: var(--primary-background-hover);
--color-destructive-background: var(--destructive-background);

View File

@@ -11,6 +11,8 @@ import {
import { useI18n } from 'vue-i18n'
import { toValue } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { t } = useI18n()
defineOptions({
@@ -50,11 +52,22 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
</DropdownMenuSub>
<DropdownMenuItem
v-else
:class="itemClass"
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'
)
"
:disabled="toValue(item.disabled) ?? !item.command"
@select="item.command?.({ originalEvent: $event, item })"
>
<i class="size-5 shrink-0" :class="item.icon" />
<!-- 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" />
<div class="mr-auto truncate" v-text="item.label" />
<i v-if="item.checked" class="icon-[lucide--check] shrink-0" />
<div

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { ZIndex } from '@primeuix/utils/zindex'
import type { MenuItem } from 'primevue/menuitem'
import {
DropdownMenuArrow,
@@ -7,13 +8,16 @@ import {
DropdownMenuRoot,
DropdownMenuTrigger
} from 'reka-ui'
import { computed, toValue } from 'vue'
import { computed, ref, 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
})
@@ -41,10 +45,20 @@ 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>
<DropdownMenuRoot v-model:open="open">
<DropdownMenuTrigger as-child>
<slot name="button">
<Button :size="buttonSize ?? 'icon'" :class="buttonClass">
@@ -60,6 +74,7 @@ const contentClass = computed(() =>
:collision-padding="10"
v-bind="$attrs"
:class="contentClass"
:style="contentStyle"
>
<slot :item-class>
<DropdownItem

View File

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

View File

@@ -0,0 +1,95 @@
<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 { useCommandStore } from '@/stores/commandStore'
import { useAuthStore } from '@/stores/authStore'
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) {
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'
)
}
const handleOpenPartnerNodesInfo = () => {
window.open(
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),
'_blank'
)
}
</script>

View File

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

View File

@@ -197,6 +197,7 @@ import { forEachNode } from '@/utils/graphTraversalUtil'
import SelectionRectangle from './SelectionRectangle.vue'
import { isCloud } from '@/platform/distribution/types'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { usePricingTableUrlLoader } from '@/platform/cloud/subscription/composables/usePricingTableUrlLoader'
import { useCreateWorkspaceUrlLoader } from '@/platform/workspace/composables/useCreateWorkspaceUrlLoader'
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
@@ -461,6 +462,7 @@ const { flags } = useFeatureFlags()
// Set up URL loaders during setup phase so useRoute/useRouter work correctly
const inviteUrlLoader = isCloud ? useInviteUrlLoader() : null
const createWorkspaceUrlLoader = isCloud ? useCreateWorkspaceUrlLoader() : null
const pricingTableUrlLoader = isCloud ? usePricingTableUrlLoader() : null
useCanvasDrop(canvasRef)
useLitegraphSettings()
useNodeBadge()
@@ -587,6 +589,19 @@ onMounted(async () => {
}
}
// Open the pricing table from URL if present (e.g., ?pricing=1 / ?pricing=team).
// Not gated on the team-workspaces flag: it also drives personal/legacy users.
if (pricingTableUrlLoader) {
try {
await pricingTableUrlLoader.loadPricingTableFromUrl()
} catch (error) {
console.error(
'[GraphCanvas] Failed to load pricing table from URL:',
error
)
}
}
// Initialize release store to fetch releases from comfy-api (fire-and-forget)
const { useReleaseStore } =
await import('@/platform/updates/common/releaseStore')

View File

@@ -1,37 +1,18 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { h, ref } from 'vue'
import { defineComponent, h, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import type { BalanceInfo, SubscriptionInfo } from '@/composables/billing/types'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import CurrentUserPopoverLegacy from './CurrentUserPopoverLegacy.vue'
// Mock all firebase modules
vi.mock('firebase/app', () => ({
initializeApp: vi.fn(),
getApp: vi.fn()
}))
vi.mock('firebase/auth', () => ({
getAuth: vi.fn(),
setPersistence: vi.fn(),
browserLocalPersistence: {},
onAuthStateChanged: vi.fn(),
signInWithEmailAndPassword: vi.fn(),
signOut: vi.fn()
}))
// Mock pinia
vi.mock('pinia')
// Mock showSettingsDialog and showTopUpCreditsDialog
const mockShowSettingsDialog = vi.fn()
const mockShowTopUpCreditsDialog = vi.fn()
// Mock the settings dialog composable
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
useSettingsDialog: vi.fn(() => ({
show: mockShowSettingsDialog,
@@ -40,7 +21,6 @@ vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
}))
}))
// Mock window.open
const originalWindowOpen = window.open
beforeEach(() => {
window.open = vi.fn()
@@ -50,7 +30,6 @@ afterAll(() => {
window.open = originalWindowOpen
})
// Mock the useCurrentUser composable
const mockHandleSignOut = vi.fn()
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: vi.fn(() => ({
@@ -61,60 +40,50 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
}))
}))
// Mock the useAuthActions composable
const mockLogout = vi.fn()
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: vi.fn(() => ({
fetchBalance: vi.fn().mockResolvedValue(undefined),
logout: mockLogout
}))
}))
// Mock the dialog service
vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => ({
showTopUpCreditsDialog: mockShowTopUpCreditsDialog
}))
}))
// Mock the authStore with hoisted state for per-test manipulation
const mockAuthStoreState = vi.hoisted(() => ({
balance: {
amount_micros: 100_000,
effective_balance_micros: 100_000,
currency: 'usd'
} as {
amount_micros?: number
effective_balance_micros?: number
currency: string
},
isFetchingBalance: false
}))
function makeSubscription(
overrides: Partial<SubscriptionInfo> = {}
): SubscriptionInfo {
return {
isActive: true,
tier: 'CREATOR',
duration: 'MONTHLY',
planSlug: null,
renewalDate: null,
endDate: null,
isCancelled: false,
hasFunds: true,
...overrides
}
}
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
getAuthHeader: vi
.fn()
.mockResolvedValue({ Authorization: 'Bearer mock-token' }),
balance: mockAuthStoreState.balance,
isFetchingBalance: mockAuthStoreState.isFetchingBalance
}))
}))
// Mock the useSubscription composable
const mockFetchStatus = vi.fn().mockResolvedValue(undefined)
const mockFetchBalance = vi.fn().mockResolvedValue(undefined)
const mockIsActiveSubscription = ref(true)
const mockIsFreeTier = ref(false)
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: vi.fn(() => ({
isActiveSubscription: ref(true),
const mockTier = ref<SubscriptionInfo['tier']>('CREATOR')
const mockSubscription = ref<SubscriptionInfo | null>(makeSubscription())
const mockBalance = ref<BalanceInfo | null>(null)
const mockIsLoading = ref(false)
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => ({
isActiveSubscription: mockIsActiveSubscription,
isFreeTier: mockIsFreeTier,
subscriptionTierName: ref('Creator'),
subscriptionTier: ref('CREATOR'),
fetchStatus: mockFetchStatus
tier: mockTier,
subscription: mockSubscription,
balance: mockBalance,
isLoading: mockIsLoading,
fetchStatus: mockFetchStatus,
fetchBalance: mockFetchBalance
}))
}))
// Mock the useSubscriptionDialog composable
const mockShowPricingTable = vi.fn()
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
@@ -127,7 +96,6 @@ vi.mock(
})
)
// Mock UserAvatar component
vi.mock('@/components/common/UserAvatar.vue', () => ({
default: {
name: 'UserAvatarMock',
@@ -137,22 +105,10 @@ vi.mock('@/components/common/UserAvatar.vue', () => ({
}
}))
// Mock UserCredit component
vi.mock('@/components/common/UserCredit.vue', () => ({
default: {
name: 'UserCreditMock',
render() {
return h('div', 'Credit: 100')
}
}
}))
// Mock formatCreditsFromCents
vi.mock('@/base/credits/comfyCredits', () => ({
formatCreditsFromCents: vi.fn(({ cents }) => (cents / 100).toString())
}))
// Mock useExternalLink
vi.mock('@/composables/useExternalLink', () => ({
useExternalLink: vi.fn(() => ({
buildDocsUrl: vi.fn((path) => `https://docs.comfy.org${path}`),
@@ -162,14 +118,12 @@ vi.mock('@/composables/useExternalLink', () => ({
}))
}))
// Mock useTelemetry
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => ({
trackAddApiCreditButtonClicked: vi.fn()
}))
}))
// Mock isCloud with hoisted state for per-test toggling
const mockIsCloud = vi.hoisted(() => ({ value: true }))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
@@ -178,25 +132,37 @@ vi.mock('@/platform/distribution/types', () => ({
}))
vi.mock('@/platform/cloud/subscription/components/SubscribeButton.vue', () => ({
default: {
default: defineComponent({
name: 'SubscribeButtonMock',
render() {
return h('div', 'Subscribe Button')
emits: ['subscribed'],
setup(_, { emit }) {
return () =>
h(
'button',
{
'data-testid': 'subscribe-button-mock',
onClick: () => emit('subscribed')
},
'Subscribe Button'
)
}
}
})
}))
describe('CurrentUserPopoverLegacy', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCloud.value = true
mockIsActiveSubscription.value = true
mockIsFreeTier.value = false
mockAuthStoreState.balance = {
amount_micros: 100_000,
effective_balance_micros: 100_000,
mockTier.value = 'CREATOR'
mockSubscription.value = makeSubscription()
mockBalance.value = {
amountMicros: 100_000,
effectiveBalanceMicros: 100_000,
currency: 'usd'
}
mockAuthStoreState.isFetchingBalance = false
mockIsLoading.value = false
})
function renderComponent() {
@@ -230,7 +196,47 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('test@example.com')).toBeInTheDocument()
})
it('calls formatCreditsFromCents with correct parameters and displays formatted credits', () => {
it('fetches the balance through the billing facade on mount', () => {
renderComponent()
expect(mockFetchBalance).toHaveBeenCalled()
})
it('refreshes subscription status through the billing facade after subscribing', async () => {
mockIsActiveSubscription.value = false
const { user } = renderComponent()
await user.click(screen.getByTestId('subscribe-button-mock'))
expect(mockFetchStatus).toHaveBeenCalled()
})
describe('subscription tier badge', () => {
it('renders the tier name derived from the facade tier', () => {
renderComponent()
expect(screen.getByText('Creator')).toBeInTheDocument()
})
it('renders the yearly tier name when the facade subscription is annual', () => {
mockSubscription.value = makeSubscription({ duration: 'ANNUAL' })
renderComponent()
expect(screen.getByText('Creator Yearly')).toBeInTheDocument()
})
it('hides the badge when the facade reports no tier', () => {
mockTier.value = null
mockSubscription.value = null
renderComponent()
expect(screen.queryByText('Creator')).not.toBeInTheDocument()
})
})
it('formats and displays the facade balance', () => {
renderComponent()
expect(formatCreditsFromCents).toHaveBeenCalledWith({
@@ -245,6 +251,14 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('1000')).toBeInTheDocument()
})
it('shows a skeleton instead of the balance while billing is loading', () => {
mockIsLoading.value = true
renderComponent()
expect(screen.queryByText('1000')).not.toBeInTheDocument()
})
it('renders logout menu item with correct text', () => {
renderComponent()
@@ -324,11 +338,11 @@ describe('CurrentUserPopoverLegacy', () => {
expect(onClose).toHaveBeenCalledTimes(1)
})
describe('effective_balance_micros handling', () => {
it('uses effective_balance_micros when present (positive balance)', () => {
mockAuthStoreState.balance = {
amount_micros: 200_000,
effective_balance_micros: 150_000,
describe('facade balance handling', () => {
it('uses effectiveBalanceMicros when present (positive balance)', () => {
mockBalance.value = {
amountMicros: 200_000,
effectiveBalanceMicros: 150_000,
currency: 'usd'
}
@@ -345,10 +359,10 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('1500')).toBeInTheDocument()
})
it('uses effective_balance_micros when zero', () => {
mockAuthStoreState.balance = {
amount_micros: 100_000,
effective_balance_micros: 0,
it('uses effectiveBalanceMicros when zero', () => {
mockBalance.value = {
amountMicros: 100_000,
effectiveBalanceMicros: 0,
currency: 'usd'
}
@@ -365,10 +379,10 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('0')).toBeInTheDocument()
})
it('uses effective_balance_micros when negative', () => {
mockAuthStoreState.balance = {
amount_micros: 0,
effective_balance_micros: -50_000,
it('uses effectiveBalanceMicros when negative', () => {
mockBalance.value = {
amountMicros: 0,
effectiveBalanceMicros: -50_000,
currency: 'usd'
}
@@ -385,9 +399,9 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('-500')).toBeInTheDocument()
})
it('falls back to amount_micros when effective_balance_micros is missing', () => {
mockAuthStoreState.balance = {
amount_micros: 100_000,
it('falls back to amountMicros when effectiveBalanceMicros is missing', () => {
mockBalance.value = {
amountMicros: 100_000,
currency: 'usd'
}
@@ -404,10 +418,8 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('1000')).toBeInTheDocument()
})
it('falls back to 0 when both effective_balance_micros and amount_micros are missing', () => {
mockAuthStoreState.balance = {
currency: 'usd'
}
it('falls back to 0 when the facade reports no balance', () => {
mockBalance.value = null
renderComponent()
@@ -466,8 +478,11 @@ describe('CurrentUserPopoverLegacy', () => {
})
it('hides subscribe button', () => {
mockIsActiveSubscription.value = false
renderComponent()
expect(screen.queryByText('Subscribe Button')).not.toBeInTheDocument()
expect(
screen.queryByTestId('subscribe-button-mock')
).not.toBeInTheDocument()
})
it('still shows partner nodes menu item', () => {

View File

@@ -32,12 +32,7 @@
<!-- Credits Section -->
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<Skeleton
v-if="authStore.isFetchingBalance"
width="4rem"
height="1.25rem"
class="w-full"
/>
<Skeleton v-if="isLoading" width="4rem" height="1.25rem" class="w-full" />
<span v-else class="text-base font-semibold text-base-foreground">{{
formattedBalance
}}</span>
@@ -162,16 +157,15 @@ import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import UserAvatar from '@/components/common/UserAvatar.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useExternalLink } from '@/composables/useExternalLink'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useWorkspaceTierLabel } from '@/platform/workspace/composables/useWorkspaceTierLabel'
import { useDialogService } from '@/services/dialogService'
import { useAuthStore } from '@/stores/authStore'
const emit = defineEmits<{
close: []
@@ -181,25 +175,29 @@ const { buildDocsUrl, docsPaths } = useExternalLink()
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
useCurrentUser()
const authActions = useAuthActions()
const authStore = useAuthStore()
const settingsDialog = useSettingsDialog()
const dialogService = useDialogService()
const {
isActiveSubscription,
isFreeTier,
subscriptionTierName,
subscriptionTier,
fetchStatus
} = useSubscription()
tier,
subscription,
balance,
isLoading,
fetchStatus,
fetchBalance
} = useBillingContext()
const { formatTierName } = useWorkspaceTierLabel()
const subscriptionDialog = useSubscriptionDialog()
const { locale } = useI18n()
const subscriptionTierName = computed(() =>
formatTierName(tier.value, subscription.value?.duration === 'ANNUAL')
)
const formattedBalance = computed(() => {
const cents =
authStore.balance?.effective_balance_micros ??
authStore.balance?.amount_micros ??
0
balance.value?.effectiveBalanceMicros ?? balance.value?.amountMicros ?? 0
return formatCreditsFromCents({
cents,
locale: locale.value,
@@ -211,12 +209,12 @@ const formattedBalance = computed(() => {
})
const canUpgrade = computed(() => {
const tier = subscriptionTier.value
const currentTier = tier.value
return (
tier === 'FREE' ||
tier === 'FOUNDERS_EDITION' ||
tier === 'STANDARD' ||
tier === 'CREATOR'
currentTier === 'FREE' ||
currentTier === 'FOUNDERS_EDITION' ||
currentTier === 'STANDARD' ||
currentTier === 'CREATOR'
)
})
@@ -270,6 +268,6 @@ const handleSubscribed = async () => {
}
onMounted(() => {
void authActions.fetchBalance()
void fetchBalance()
})
</script>

View File

@@ -22,6 +22,8 @@ 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'
},
@@ -54,6 +56,7 @@ const variants = [
'destructive-textonly',
'link',
'base',
'tertiary',
'overlay-white',
'gradient'
] as const satisfies Array<ButtonVariants['variant']>

View File

@@ -8,6 +8,7 @@ import type {
CurrentTeamCreditStop,
Plan,
PreviewSubscribeResponse,
SubscribeOptions,
SubscribeResponse,
SubscriptionDuration,
SubscriptionTier,
@@ -43,8 +44,7 @@ export interface BillingActions {
fetchBalance: () => Promise<void>
subscribe: (
planSlug: string,
returnUrl?: string,
cancelUrl?: string
options?: SubscribeOptions
) => Promise<SubscribeResponse | void>
previewSubscribe: (
planSlug: string

View File

@@ -1,6 +1,8 @@
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
@@ -20,12 +22,14 @@ 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,
@@ -44,15 +48,25 @@ vi.mock('@vueuse/core', async (importOriginal) => {
}
})
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get teamWorkspacesEnabled() {
return mockTeamWorkspacesEnabled.value
}
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
}
})
}))
return {
useFeatureFlags: () => ({
flags: {
get teamWorkspacesEnabled() {
return mockTeamWorkspacesEnabled.value
}
}
})
}
})
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
@@ -64,7 +78,7 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
? { id: 'personal-123', type: 'personal' }
: { id: 'team-456', type: 'team' }
},
updateActiveWorkspace: vi.fn()
updateActiveWorkspace: mockUpdateActiveWorkspace
})
}))
@@ -142,11 +156,28 @@ describe('useBillingContext', () => {
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
})
it('returns legacy type for personal workspace', () => {
it('selects legacy type when team workspaces are disabled', () => {
mockTeamWorkspacesEnabled.value = false
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()
@@ -221,6 +252,42 @@ describe('useBillingContext', () => {
expect(() => showSubscriptionDialog()).not.toThrow()
})
it('reinitializes workspace billing when the type flips on after legacy init', async () => {
mockTeamWorkspacesEnabled.value = false
mockIsPersonal.value = true
const { type, initialize } = useBillingContext()
await initialize()
await nextTick()
expect(type.value).toBe('legacy')
expect(workspaceApi.getBillingStatus).not.toHaveBeenCalled()
// Authenticated remote config resolves the flag on for the same workspace
mockTeamWorkspacesEnabled.value = true
await vi.waitFor(() => {
expect(type.value).toBe('workspace')
expect(workspaceApi.getBillingStatus).toHaveBeenCalled()
})
})
describe('subscription mirror to workspace store', () => {
it('mirrors subscription for personal workspaces when team workspaces are enabled', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = true
const { initialize } = useBillingContext()
await initialize()
await nextTick()
expect(mockUpdateActiveWorkspace).toHaveBeenCalledWith({
isSubscribed: true,
subscriptionPlan: null
})
})
})
describe('getMaxSeats', () => {
it('returns 1 for personal workspaces regardless of tier', () => {
const { getMaxSeats } = useBillingContext()

View File

@@ -7,6 +7,7 @@ import {
getTierFeatures
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { SubscribeOptions } from '@/platform/workspace/api/workspaceApi'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import type {
@@ -27,11 +28,11 @@ import { useWorkspaceBilling } from '@/platform/workspace/composables/useWorkspa
const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
/**
* Unified billing context that automatically switches between legacy (user-scoped)
* and workspace billing based on the active workspace type.
* Unified billing context that selects the billing implementation by build/flag.
*
* - Personal workspaces use legacy billing via /customers/* endpoints
* - Team workspaces use workspace billing via /billing/* endpoints
* - 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
*
* The context automatically initializes when the workspace changes and provides
* a unified interface for subscription status, balance, and billing actions.
@@ -92,16 +93,14 @@ function useBillingContextInternal(): BillingContext {
const error = ref<string | null>(null)
/**
* 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)
* 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
*/
const type = computed<BillingType>(() => {
if (!flags.teamWorkspacesEnabled) return 'legacy'
return store.isInPersonalWorkspace ? 'legacy' : 'workspace'
})
const type = computed<BillingType>(() =>
flags.teamWorkspacesEnabled ? 'workspace' : 'legacy'
)
const activeContext = computed(() =>
type.value === 'legacy' ? getLegacyBilling() : getWorkspaceBilling()
@@ -173,7 +172,7 @@ function useBillingContextInternal(): BillingContext {
watch(
subscription,
(sub) => {
if (!sub || store.isInPersonalWorkspace) return
if (!sub) return
store.updateActiveWorkspace({
isSubscribed: sub.isActive && !sub.isCancelled,
@@ -183,10 +182,15 @@ function useBillingContextInternal(): BillingContext {
{ immediate: true }
)
// Initialize billing when workspace changes
// Reinitialize when the workspace or the resolved billing type changes.
// type can flip after setup (e.g. when the team-workspaces flag resolves from
// authenticated config), which swaps the active backend and needs a fresh init.
watch(
() => store.activeWorkspace?.id,
async (newWorkspaceId, oldWorkspaceId) => {
[() => store.activeWorkspace?.id, () => type.value],
async (
[newWorkspaceId, newType],
[oldWorkspaceId, oldType] = [undefined, undefined]
) => {
if (!newWorkspaceId) {
// No workspace selected - reset state
isInitialized.value = false
@@ -194,8 +198,8 @@ function useBillingContextInternal(): BillingContext {
return
}
if (newWorkspaceId !== oldWorkspaceId) {
// Workspace changed - reinitialize
if (newWorkspaceId !== oldWorkspaceId || newType !== oldType) {
// Workspace or billing type changed - reinitialize
isInitialized.value = false
try {
await initialize()
@@ -233,12 +237,8 @@ function useBillingContextInternal(): BillingContext {
return activeContext.value.fetchBalance()
}
async function subscribe(
planSlug: string,
returnUrl?: string,
cancelUrl?: string
) {
return activeContext.value.subscribe(planSlug, returnUrl, cancelUrl)
async function subscribe(planSlug: string, options?: SubscribeOptions) {
return activeContext.value.subscribe(planSlug, options)
}
async function previewSubscribe(planSlug: string) {
@@ -258,6 +258,15 @@ function useBillingContextInternal(): BillingContext {
}
async function topup(amountCents: number) {
if (
!Number.isInteger(amountCents) ||
amountCents <= 0 ||
amountCents % 100 !== 0
) {
throw new Error(
'Top-up amount must be a positive whole-dollar cent value'
)
}
return activeContext.value.topup(amountCents)
}

View File

@@ -6,6 +6,7 @@ import type {
BillingStatus,
BillingSubscriptionStatus,
PreviewSubscribeResponse,
SubscribeOptions,
SubscribeResponse
} from '@/platform/workspace/api/workspaceApi'
import { useAuthStore } from '@/stores/authStore'
@@ -147,8 +148,7 @@ export function useLegacyBilling(): BillingState & BillingActions {
async function subscribe(
_planSlug: string,
_returnUrl?: string,
_cancelUrl?: string
_options?: SubscribeOptions
): Promise<SubscribeResponse | void> {
// Legacy billing uses Stripe checkout flow via useSubscription
await legacySubscribe()

View File

@@ -2,6 +2,7 @@ import { computed, reactive, readonly } from 'vue'
import { isCloud, isNightly } from '@/platform/distribution/types'
import {
cachedPersonalWorkspaceBillingReady,
cachedTeamWorkspacesEnabled,
isAuthenticatedConfigLoaded,
remoteConfig
@@ -22,6 +23,7 @@ export enum ServerFeatureFlag {
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled',
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled',
PERSONAL_WORKSPACE_BILLING_READY = 'personal_workspace_billing_ready',
USER_SECRETS_ENABLED = 'user_secrets_enabled',
NODE_REPLACEMENTS = 'node_replacements',
NODE_LIBRARY_ESSENTIALS_ENABLED = 'node_library_essentials_enabled',
@@ -117,6 +119,33 @@ export function useFeatureFlags() {
api.getServerFeature(ServerFeatureFlag.TEAM_WORKSPACES_ENABLED, false)
)
},
/**
* Whether the backend is ready to serve personal billing over the
* workspace path (/api/billing/*). Gates the personal cutover so personal
* subscribers stay on legacy billing until the backend emits this key,
* even when team workspaces are already enabled.
* IMPORTANT: Defaults false until authenticated remote config is loaded,
* matching teamWorkspacesEnabled, so we never route personal billing to a
* blank workspace identity before the backend is live.
*/
get personalWorkspaceBillingReady() {
const override = getDevOverride<boolean>(
ServerFeatureFlag.PERSONAL_WORKSPACE_BILLING_READY
)
if (override !== undefined) return override
if (!isCloud) return false
if (!isAuthenticatedConfigLoaded.value)
return cachedPersonalWorkspaceBillingReady.value ?? false
return (
remoteConfig.value.personal_workspace_billing_ready ??
api.getServerFeature(
ServerFeatureFlag.PERSONAL_WORKSPACE_BILLING_READY,
false
)
)
},
get userSecretsEnabled() {
return resolveFlag(
ServerFeatureFlag.USER_SECRETS_ENABLED,

View File

@@ -2463,11 +2463,16 @@
"creditSliderSave": "Save {percent}% ({amount})",
"renewsDate": "Renews {date}",
"expiresDate": "Expires {date}",
"renewsOnDate": "Renews on {date}",
"endsOnDate": "Ends on {date}",
"manageSubscription": "Manage subscription",
"managePayment": "Manage Payment",
"manageBilling": "Manage billing",
"changePlan": "Change plan",
"cancelSubscription": "Cancel Subscription",
"cancelPlan": "Cancel plan",
"canceled": "Canceled",
"resubscribe": "Resubscribe",
"reactivatePlan": "Reactivate plan",
"resubscribeTo": "Resubscribe to {plan}",
"resubscribeSuccess": "Subscription reactivated successfully",
"canceledCard": {
@@ -2477,7 +2482,7 @@
"cancelSuccess": "Subscription cancelled successfully",
"cancelDialog": {
"title": "Cancel subscription",
"description": "Your access continues until {date}. You won't be charged again, and your workspace and credits will be preserved. You can resubscribe anytime.",
"description": "Your plan stays active until {date}. You won't be charged again. Any unused monthly credits will expire, but additional credits are kept. Resubscribe anytime.",
"endOfBillingPeriod": "end of billing period",
"keepSubscription": "Keep subscription",
"confirmCancel": "Cancel subscription",
@@ -2492,9 +2497,7 @@
"failed": "Failed to change plan",
"notAllowed": "This plan change is not available",
"paymentMethodRequired": "A payment method is required to change plans",
"paymentPageBlocked": "Couldn't open the payment page — please try again",
"memberRemovalFailed": "Couldn't remove {email} from the team — some members may already be removed and your plan was not changed",
"failedAfterMemberRemoval": "Team members were removed, but the plan change didn't complete — please try again or contact support"
"paymentPageBlocked": "Couldn't open the payment page — please try again"
},
"partnerNodesBalance": "\"Partner Nodes\" Credit Balance",
"partnerNodesDescription": "For running commercial/proprietary models",
@@ -2509,10 +2512,34 @@
"creditsRemainingThisMonth": "Included (Refills {date})",
"creditsRemainingThisYear": "Included (Refills {date})",
"creditsYouveAdded": "Additional",
"remaining": "remaining",
"refillsDate": "Refills {date}",
"creditsUsed": "{used} used",
"creditsLeftOfTotal": "{remaining} left of {total}",
"additionalCredits": "Additional credits",
"additionalCreditsInUse": "In use",
"usedAfterMonthly": "Used after monthly runs out",
"monthlyCreditsUsedUpTitle": "Monthly credits are used up. Refills {date}",
"monthlyCreditsUsedUpDescription": "You're now spending additional credits.",
"outOfCreditsTitle": "You're out of credits. Credits refill {date}",
"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",
"yourPlanIncludes": "Your plan includes:",
"whatsIncluded": "What's included:",
"teamPlanName": "Team",
"teamPlanIncludes": "Your plan includes everything in {plan}, plus:",
"teamPerks": {
"inviteMembers": "Invite members",
"concurrentRuns": "Members can run workflows concurrently",
"sharedCreditPool": "Shared credit pool for all members",
"rolePermissions": "Role-based permissions"
},
"freePerks": {
"maxRuntime": "{duration} max runtime"
},
"viewMoreDetails": "View more details",
"learnMore": "Learn more",
"billedMonthly": "Billed monthly",
@@ -2570,6 +2597,7 @@
"subscribeToRunFull": "Subscribe to Run",
"subscribeForMore": "Upgrade",
"upgradeToAddCredits": "Upgrade to add credits",
"subscribe": "Subscribe",
"subscribeNow": "Subscribe Now",
"subscribeToComfyCloud": "Subscribe to Comfy Cloud",
"workspaceNotSubscribed": "This workspace is not on a subscription",
@@ -2606,9 +2634,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",
"checkoutComingSoon": "Team plan checkout is coming soon."
"currentPlan": "Current plan"
},
"enterprise": {
"name": "Enterprise",
@@ -2638,6 +2666,7 @@
"upgradeCta": "View plans"
},
"partnerNodesCredits": "Partner nodes pricing",
"partnerNodesPricingTable": "Partner Nodes pricing table",
"plansAndPricing": "Plans & pricing",
"managePlan": "Manage plan",
"upgrade": "UPGRADE",
@@ -2648,8 +2677,6 @@
"monthlyCreditsPerMemberLabel": "Monthly credits / member",
"maxMembersLabel": "Max. members",
"yearlyCreditsLabel": "Total yearly credits",
"membersLabel": "Up to {count} members",
"nextMonthInvoice": "Next month invoice",
"memberCount": "{count} member | {count} members",
"maxDurationLabel": "Max run duration",
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
@@ -2664,7 +2691,7 @@
"upgradeTo": "Upgrade to {plan}",
"changeTo": "Change to {plan}",
"maxDuration": {
"free": "30 min",
"free": "10 min",
"standard": "30 min",
"creator": "30 min",
"pro": "1 hr",
@@ -2677,10 +2704,11 @@
"preview": {
"confirmPayment": "Confirm your payment",
"confirmPlanChange": "Confirm your plan change",
"startingToday": "Starting today",
"startingToday": "Starts 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",
@@ -2691,6 +2719,21 @@
"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",
"switchesToday": "Switches today",
"startsOn": "Starts {date}",
"yearlySubscription": "Yearly subscription",
"newMonthlySubscription": "New monthly subscription",
"creditFromCurrent": "Credit from current {plan}",
"currentMonthly": "monthly",
"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",
@@ -2702,8 +2745,12 @@
},
"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."
"receiptEmailed": "A receipt has been emailed to you.",
"sendInvites": "Send invites"
}
},
"userSettings": {
@@ -2719,7 +2766,7 @@
"workspacePanel": {
"invite": "Invite",
"inviteMember": "Invite member",
"inviteLimitReached": "You've reached the maximum of 50 members",
"inviteLimitReached": "You've reached the maximum of {count} members",
"tabs": {
"dashboard": "Dashboard",
"planCredits": "Plan & Credits",
@@ -2729,7 +2776,8 @@
"placeholder": "Dashboard workspace settings"
},
"members": {
"membersCount": "{count}/{maxSeats} Members",
"header": "Members",
"membersCount": "{count} of {maxSeats} members",
"pendingInvitesCount": "{count} pending invite | {count} pending invites",
"tabs": {
"active": "Active",
@@ -2738,26 +2786,30 @@
"columns": {
"inviteDate": "Invite date",
"expiryDate": "Expiry date",
"joinDate": "Join date"
"role": "Role"
},
"actions": {
"copyLink": "Copy invite link",
"revokeInvite": "Revoke invite",
"resendInvite": "Resend invite",
"cancelInvite": "Cancel invite",
"changeRole": "Change role",
"removeMember": "Remove member"
},
"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",
"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",
"noInvites": "No pending invites",
"noMembers": "No members",
"personalWorkspaceMessage": "You can't invite other members to your personal workspace right now. To add members to a workspace,",
"createNewWorkspace": "create a new one."
"searchPlaceholder": "Search..."
},
"menu": {
"editWorkspace": "Edit workspace details",
"leaveWorkspace": "Leave Workspace",
"deleteWorkspace": "Delete Workspace",
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first"
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first",
"creatorCannotLeave": "The workspace creator can't leave the workspace they created"
},
"editWorkspaceDialog": {
"title": "Edit workspace details",
@@ -2781,32 +2833,38 @@
"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 subscription is required to invite members",
"titleNotSubscribed": "A Team plan is required to invite members",
"titleSingleSeat": "Your current plan supports a single seat",
"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"
"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"
},
"inviteMemberDialog": {
"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"
"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 emailed to {emails}. | Invites were emailed to {emails}.",
"seatLimitReached": "You can invite up to {count} teammate. | You can invite up to {count} teammates."
},
"createWorkspaceDialog": {
"title": "Create a new workspace",
@@ -2833,6 +2891,8 @@
"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",

View File

@@ -116,7 +116,9 @@ app
modal: 1800,
overlay: 1800,
menu: 1800,
tooltip: 1800
// Tooltips sit above modals/menus so a menu-item tooltip isn't hidden
// behind a body-portaled dropdown that lifts itself to modal + 1.
tooltip: 2000
},
theme: {
preset: ComfyUIPreset,

View File

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

View File

@@ -10,6 +10,7 @@ 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'
@@ -35,6 +36,12 @@ 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
@@ -58,7 +65,34 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
return
}
// Only paid tiers can be checked out via redirect
const validCycles: BillingCycle[] = ['monthly', 'yearly']
const billingCycle: BillingCycle = (validCycles as string[]).includes(
cycleParam
)
? (cycleParam as BillingCycle)
: '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
}
// Only paid personal tiers can be checked out via redirect
const validTierKeys: TierKey[] = ['standard', 'creator', 'pro', 'founder']
if (!(validTierKeys as string[]).includes(tierKeyParam)) {
await router.push('/')
@@ -69,11 +103,6 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
selectedTierKey.value = tierKey
const validCycles: BillingCycle[] = ['monthly', 'yearly']
if (!cycleParam || !(validCycles as string[]).includes(cycleParam)) {
cycleParam = 'monthly'
}
if (!isInitialized.value) {
await initialize()
}
@@ -81,11 +110,7 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
if (isActiveSubscription.value) {
await accessBillingPortal(undefined, false)
} else {
await performSubscriptionCheckout(
tierKey,
cycleParam as BillingCycle,
false
)
await performSubscriptionCheckout(tierKey, billingCycle, false)
}
}, reportError)
@@ -105,18 +130,18 @@ onMounted(() => {
class="size-16"
/>
<p
v-if="selectedTierKey"
v-if="planLabel"
class="font-inter text-base/normal font-normal text-base-foreground"
>
{{
t('subscription.subscribeTo', {
plan: tierDisplayName
plan: planLabel
})
}}
</p>
<ProgressSpinner v-if="selectedTierKey" class="size-8" stroke-width="4" />
<ProgressSpinner v-if="planLabel" class="size-8" stroke-width="4" />
<Button
v-if="selectedTierKey"
v-if="planLabel"
as="a"
href="/"
link

View File

@@ -0,0 +1,306 @@
import { render, screen } 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 CreditsTile from '@/platform/cloud/subscription/components/CreditsTile.vue'
type Balance = {
amountMicros: number
cloudCreditBalanceMicros?: number
prepaidBalanceMicros?: number
}
type Subscription = {
tier: string | null
duration: string | null
renewalDate: string | null
}
type TeamStop = {
id: string
credits_monthly: number
stop_usd: number
}
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()
}))
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}',
creditsUsed: '{used} used',
creditsLeftOfTotal: '{remaining} left of {total}',
additionalCredits: 'Additional credits',
additionalCreditsInUse: 'In use',
usedAfterMonthly: 'Used after monthly runs out',
monthlyCreditsUsedUpTitle:
'Monthly credits are used up. Refills {date}',
monthlyCreditsUsedUpDescription:
"You're now spending additional credits.",
outOfCreditsTitle: "You're out of credits. Credits refill {date}",
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('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)
})
})

View File

@@ -0,0 +1,366 @@
<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"
:style="creditIconStyle"
/>
<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">
{{ $t('subscription.refillsDate', { date: refillsDateShort }) }}
</span>
</div>
<div
class="h-2 w-full overflow-hidden rounded-full bg-secondary-background-hover"
>
<div
class="h-full rounded-full"
:style="{ width: usedBarWidth, backgroundColor: CREDIT_COLOR }"
/>
</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"
:style="creditIconStyle"
/>
<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') }}
<i
v-tooltip="{
value: $t('subscription.additionalCreditsTooltip'),
showDelay: 300
}"
class="icon-[lucide--info] size-4 text-muted"
/>
<span
v-if="isSpendingAdditional"
class="flex h-3.5 items-center rounded-full bg-base-foreground px-1 text-[10px] leading-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"
:style="creditIconStyle"
/>
{{ 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 Skeleton from 'primevue/skeleton'
import { computed, onBeforeUnmount, onMounted, toValue } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
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 { 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 CREDIT_COLOR = '#fabc25'
const creditIconStyle = { color: CREDIT_COLOR }
const PENDING_TOPUP_KEY = 'pending_topup_timestamp'
const TOPUP_EXPIRY_MS = 5 * 60 * 1000
const { locale, n, 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 dialogService = useDialogService()
const telemetry = useTelemetry()
const isYearly = computed(() => subscription.value?.duration === 'ANNUAL')
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
const credits = getTierCredits(tierKey.value)
if (credits === null) return null
return isYearly.value ? credits * 12 : credits
})
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())
? raw
: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
})
const monthlyTotalDisplay = computed(() => {
const total = monthlyTotalCredits.value
return total === null ? '—' : n(total)
})
const usedDisplay = computed(() => n(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 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 &&
toValue(balance) != 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: t('subscription.outOfCreditsTitle', {
date: refillsDateShort.value
}),
description: t('subscription.outOfCreditsDescription')
}
}
if (isMonthlyDepleted.value) {
return {
title: t('subscription.monthlyCreditsUsedUpTitle', {
date: refillsDateShort.value
}),
description: t('subscription.monthlyCreditsUsedUpDescription')
}
}
return null
})
async function handleRefresh() {
await Promise.all([fetchBalance(), fetchStatus()])
}
function handleAddCredits() {
telemetry?.trackAddApiCreditButtonClicked()
void dialogService.showTopUpCreditsDialog()
}
function handleUpgradeToAddCredits() {
showPricingTable()
}
function handleWindowFocus() {
const timestampStr = localStorage.getItem(PENDING_TOPUP_KEY)
if (!timestampStr) return
const timestamp = parseInt(timestampStr, 10)
if (Date.now() - timestamp > TOPUP_EXPIRY_MS) {
localStorage.removeItem(PENDING_TOPUP_KEY)
return
}
void handleRefresh()
localStorage.removeItem(PENDING_TOPUP_KEY)
}
onMounted(() => {
window.addEventListener('focus', handleWindowFocus)
void handleRefresh()
})
onBeforeUnmount(() => {
window.removeEventListener('focus', handleWindowFocus)
})
</script>

View File

@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { render, screen } from '@testing-library/vue'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import FreeTierDialogContent from './FreeTierDialogContent.vue'
const mockRenewalDate = vi.hoisted(() => ({ value: null as string | null }))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => ({
renewalDate: mockRenewalDate
}))
}))
function renderComponent() {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return render(FreeTierDialogContent, {
global: {
plugins: [i18n]
}
})
}
describe('FreeTierDialogContent', () => {
it('renders the next refresh line formatted from the facade renewalDate', () => {
mockRenewalDate.value = '2026-07-15T10:00:00Z'
renderComponent()
expect(
screen.getByText('Your credits refresh on Jul 15, 2026.')
).toBeInTheDocument()
})
it('hides the next refresh line when renewalDate is null', () => {
mockRenewalDate.value = null
renderComponent()
expect(screen.queryByText(/credits refresh on/)).not.toBeInTheDocument()
})
})

View File

@@ -102,9 +102,9 @@
import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing'
defineProps<{
@@ -116,7 +116,17 @@ defineEmits<{
upgrade: []
}>()
const { formattedRenewalDate } = useSubscription()
const { renewalDate } = useBillingContext()
const formattedRenewalDate = computed(() => {
if (!renewalDate.value) return ''
return new Date(renewalDate.value).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
})
const freeTierCredits = computed(() => getTierCredits('free'))
</script>

View File

@@ -7,6 +7,7 @@ import { createI18n } from 'vue-i18n'
import PricingTable from '@/platform/cloud/subscription/components/PricingTable.vue'
import Button from '@/components/ui/button/Button.vue'
import type { SubscriptionTier } from '@/platform/cloud/subscription/constants/tierPricing'
import { PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
async function flushPromises() {
@@ -23,10 +24,8 @@ function createDeferredPromise<T>() {
}
const mockIsActiveSubscription = ref(false)
const mockSubscriptionTier = ref<
'STANDARD' | 'CREATOR' | 'PRO' | 'FOUNDERS_EDITION' | null
>(null)
const mockIsYearlySubscription = ref(false)
const mockSubscriptionTier = ref<SubscriptionTier | null>(null)
const mockSubscriptionDuration = ref<'MONTHLY' | 'ANNUAL'>('MONTHLY')
const mockAccessBillingPortal = vi.fn()
const mockReportError = vi.fn()
const mockTrackBeginCheckout = vi.fn()
@@ -65,13 +64,25 @@ Object.defineProperty(globalThis, 'localStorage', {
writable: true
})
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: computed(() => mockIsActiveSubscription.value),
isFreeTier: computed(() => false),
subscriptionTier: computed(() => mockSubscriptionTier.value),
isYearlySubscription: computed(() => mockIsYearlySubscription.value),
subscriptionStatus: ref(null)
isFreeTier: computed(() => mockSubscriptionTier.value === 'FREE'),
tier: computed(() => mockSubscriptionTier.value),
subscription: computed(() =>
mockSubscriptionTier.value
? {
isActive: mockIsActiveSubscription.value,
tier: mockSubscriptionTier.value,
duration: mockSubscriptionDuration.value,
planSlug: null,
renewalDate: null,
endDate: null,
isCancelled: false,
hasFunds: true
}
: null
)
})
}))
@@ -217,7 +228,7 @@ describe('PricingTable', () => {
vi.clearAllMocks()
mockIsActiveSubscription.value = false
mockSubscriptionTier.value = null
mockIsYearlySubscription.value = false
mockSubscriptionDuration.value = 'MONTHLY'
mockUserId.value = 'user-123'
mockAccessBillingPortal.mockReset()
mockAccessBillingPortal.mockResolvedValue(true)
@@ -362,6 +373,7 @@ describe('PricingTable', () => {
it('should not call accessBillingPortal when clicking current plan', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'CREATOR'
mockSubscriptionDuration.value = 'ANNUAL'
renderComponent()
await flushPromises()
@@ -370,12 +382,29 @@ describe('PricingTable', () => {
.getAllByRole('button')
.find((b) => b.textContent?.includes('Current Plan'))
expect(currentPlanButton).toBeDefined()
expect(currentPlanButton).toBeDisabled()
await userEvent.click(currentPlanButton!)
await flushPromises()
expect(mockAccessBillingPortal).not.toHaveBeenCalled()
})
it('does not highlight a current plan when the facade duration differs from the selected cycle', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'CREATOR'
mockSubscriptionDuration.value = 'MONTHLY'
renderComponent()
await flushPromises()
const currentPlanButton = screen
.getAllByRole('button')
.find((b) => b.textContent?.includes('Current Plan'))
expect(currentPlanButton).toBeUndefined()
})
it('should initiate checkout instead of billing portal for new subscribers', async () => {
mockIsActiveSubscription.value = false

View File

@@ -263,8 +263,8 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import {
TIER_PRICING,
TIER_TO_KEY
@@ -361,9 +361,13 @@ const tiers: PricingTierConfig[] = [
const {
isActiveSubscription,
isFreeTier,
subscriptionTier,
isYearlySubscription
} = useSubscription()
tier: subscriptionTier,
subscription
} = useBillingContext()
const isYearlySubscription = computed(
() => subscription.value?.duration === 'ANNUAL'
)
const telemetry = useTelemetry()
const { userId } = storeToRefs(useAuthStore())
const { accessBillingPortal, reportError } = useAuthActions()

View File

@@ -16,7 +16,6 @@ import { onBeforeUnmount, ref, watch } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { isCloud } from '@/platform/distribution/types'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { cn } from '@comfyorg/tailwind-utils'
@@ -38,8 +37,8 @@ const emit = defineEmits<{
subscribed: []
}>()
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
const { subscriptionTier } = useSubscription()
const { isActiveSubscription, showSubscriptionDialog, tier } =
useBillingContext()
const isAwaitingStripeSubscription = ref(false)
watch(
@@ -53,9 +52,11 @@ watch(
)
const handleSubscribe = () => {
useTelemetry()?.trackSubscription('subscribe_clicked', {
current_tier: subscriptionTier.value?.toLowerCase()
})
if (isCloud) {
useTelemetry()?.trackSubscription('subscribe_clicked', {
current_tier: tier.value?.toLowerCase()
})
}
isAwaitingStripeSubscription.value = true
showSubscriptionDialog()
}

View File

@@ -0,0 +1,67 @@
<template>
<div
class="flex items-center justify-between border-t border-interface-stroke pt-3"
>
<div class="flex gap-2">
<Button
variant="muted-textonly"
class="text-xs text-text-secondary"
@click="handleLearnMoreClick"
>
<i class="pi pi-question-circle text-xs text-text-secondary" />
{{ $t('subscription.learnMore') }}
</Button>
<Button
variant="muted-textonly"
class="text-xs text-text-secondary"
@click="handleOpenPartnerNodesInfo"
>
<i class="pi pi-question-circle text-xs text-text-secondary" />
{{ $t('subscription.partnerNodesPricingTable') }}
</Button>
<Button
variant="muted-textonly"
class="text-xs text-text-secondary"
:loading="isLoadingSupport"
@click="handleMessageSupport"
>
<i class="pi pi-comment text-xs text-text-secondary" />
{{ $t('subscription.messageSupport') }}
</Button>
</div>
<Button
variant="muted-textonly"
class="text-xs text-text-secondary"
@click="handleInvoiceHistory"
>
{{ $t('subscription.invoiceHistory') }}
<i class="pi pi-external-link text-xs text-text-secondary" />
</Button>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useExternalLink } from '@/composables/useExternalLink'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
const { buildDocsUrl, docsPaths } = useExternalLink()
const { manageSubscription } = useBillingContext()
const { isLoadingSupport, handleMessageSupport, handleLearnMoreClick } =
useSubscriptionActions()
async function handleInvoiceHistory() {
await manageSubscription()
}
function handleOpenPartnerNodesInfo() {
window.open(
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),
'_blank'
)
}
</script>

View File

@@ -132,6 +132,18 @@ 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',
tiers: {
founder: {
name: "Founder's Edition",
@@ -200,6 +212,7 @@ 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>',
@@ -240,7 +253,6 @@ 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', () => {
@@ -249,7 +261,6 @@ 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', () => {
@@ -266,58 +277,19 @@ describe('SubscriptionPanel', () => {
expect(container.textContent).toContain('Expires 2024-12-31')
})
it('displays FOUNDERS_EDITION tier correctly', () => {
it('displays FOUNDERS_EDITION tier without the custom-LoRA perk', () => {
mockSubscriptionTier.value = 'FOUNDERS_EDITION'
const { container } = createComponent()
expect(container.textContent).toContain("Founder's Edition")
expect(container.textContent).toContain('5,460')
expect(container.textContent).toContain('RTX 6000 Pro (96GB VRAM)')
expect(container.textContent).not.toContain('Import your own LoRAs')
})
it('displays CREATOR tier correctly', () => {
it('displays CREATOR tier with the custom-LoRA perk', () => {
mockSubscriptionTier.value = 'CREATOR'
const { container } = createComponent()
expect(container.textContent).toContain('Creator')
expect(container.textContent).toContain('7,400')
})
})
describe('credit display functionality', () => {
it('displays dynamic credit values correctly', () => {
const { container } = createComponent()
expect(container.textContent).toContain('10.00 Credits')
expect(container.textContent).toContain('5.00 Credits')
})
it('shows loading skeleton when fetching balance', () => {
mockCreditsData.isLoadingBalance = true
createComponent()
expect(
screen.getAllByRole('status', { name: 'Loading' }).length
).toBeGreaterThan(0)
})
it('hides skeleton when balance loaded', () => {
mockCreditsData.isLoadingBalance = false
createComponent()
expect(screen.queryAllByRole('status', { name: 'Loading' })).toHaveLength(
0
)
})
it('renders refill date with literal slashes', () => {
vi.useFakeTimers()
vi.stubEnv('TZ', 'UTC')
try {
mockIsActiveSubscription.value = true
const { container } = createComponent()
expect(container.textContent).toMatch(
/Included \(Refills \d{2}\/\d{2}\/\d{2}\)/
)
expect(container.textContent).not.toContain('&#x2F;')
} finally {
vi.useRealTimers()
vi.unstubAllEnvs()
}
expect(container.textContent).toContain('Import your own LoRAs')
})
})
@@ -335,15 +307,6 @@ 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', () => {
@@ -353,14 +316,5 @@ describe('SubscriptionPanel', () => {
const supportButton = findButtonByText('Message Support')
expect(supportButton).toBeDisabled()
})
it('should show loading state on refresh button when loading balance', () => {
mockCreditsData.isLoadingBalance = true
createComponent()
const refreshButton = screen.getByRole('button', {
name: 'Refresh credits'
})
expect(refreshButton).toBeDisabled()
})
})
})

View File

@@ -17,51 +17,13 @@
</div>
</div>
<!-- Workspace mode: workspace-aware subscription content -->
<!-- Workspace mode: workspace-aware subscription content (renders its own footer) -->
<SubscriptionPanelContentWorkspace v-if="teamWorkspacesEnabled" />
<!-- Legacy mode: user-level subscription content -->
<SubscriptionPanelContentLegacy v-else />
<div
class="flex items-center justify-between border-t border-interface-stroke pt-3"
>
<div class="flex gap-2">
<Button
variant="muted-textonly"
class="text-xs text-text-secondary"
@click="handleLearnMoreClick"
>
<i class="pi pi-question-circle text-xs text-text-secondary" />
{{ $t('subscription.learnMore') }}
</Button>
<Button
variant="muted-textonly"
class="text-xs text-text-secondary"
@click="handleOpenPartnerNodesInfo"
>
<i class="pi pi-question-circle text-xs text-text-secondary" />
{{ $t('subscription.partnerNodesCredits') }}
</Button>
<Button
variant="muted-textonly"
class="text-xs text-text-secondary"
:loading="isLoadingSupport"
@click="handleMessageSupport"
>
<i class="pi pi-comment text-xs text-text-secondary" />
{{ $t('subscription.messageSupport') }}
</Button>
</div>
<Button
variant="muted-textonly"
class="text-xs text-text-secondary"
@click="handleInvoiceHistory"
>
{{ $t('subscription.invoiceHistory') }}
<i class="pi pi-external-link text-xs text-text-secondary" />
</Button>
</div>
<template v-else>
<SubscriptionPanelContentLegacy />
<SubscriptionFooterLinks />
</template>
</div>
</div>
</template>
@@ -70,12 +32,10 @@
import { computed, defineAsyncComponent } from 'vue'
import CloudBadge from '@/components/topbar/CloudBadge.vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useExternalLink } from '@/composables/useExternalLink'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import SubscriptionFooterLinks from '@/platform/cloud/subscription/components/SubscriptionFooterLinks.vue'
import SubscriptionPanelContentLegacy from '@/platform/cloud/subscription/components/SubscriptionPanelContentLegacy.vue'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
import { isCloud } from '@/platform/distribution/types'
const SubscriptionPanelContentWorkspace = defineAsyncComponent(
@@ -88,21 +48,5 @@ const teamWorkspacesEnabled = computed(
() => isCloud && flags.teamWorkspacesEnabled
)
const { buildDocsUrl, docsPaths } = useExternalLink()
const { isActiveSubscription, manageSubscription } = useBillingContext()
const { isLoadingSupport, handleMessageSupport, handleLearnMoreClick } =
useSubscriptionActions()
const handleInvoiceHistory = async () => {
await manageSubscription()
}
const handleOpenPartnerNodesInfo = () => {
window.open(
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),
'_blank'
)
}
const { isActiveSubscription } = useBillingContext()
</script>

View File

@@ -65,100 +65,8 @@
</div>
<div class="flex flex-col gap-6 pt-9 lg:flex-row">
<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 class="w-full lg:max-w-md">
<CreditsTile />
</div>
<div class="flex flex-col gap-2">
@@ -207,26 +115,23 @@
</template>
<script setup lang="ts">
import Skeleton from 'primevue/skeleton'
import { computed, onBeforeUnmount, onMounted } from 'vue'
import { computed } 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()
@@ -239,12 +144,10 @@ const {
formattedEndDate,
subscriptionTier,
subscriptionTierName,
subscriptionStatus,
isYearlySubscription
} = useSubscription()
const { show: showSubscriptionDialog, showPricingTable } =
useSubscriptionDialog()
const { show: showSubscriptionDialog } = useSubscriptionDialog()
const tierKey = computed(() => {
const tier = subscriptionTier.value
@@ -255,89 +158,11 @@ 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 { 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)
})
const { handleRefresh } = useSubscriptionActions()
</script>
<style scoped>

View File

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

View File

@@ -0,0 +1,235 @@
import { fromAny } from '@total-typescript/shoehorn'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { usePricingTableUrlLoader } from './usePricingTableUrlLoader'
const preservedQueryMocks = vi.hoisted(() => ({
clearPreservedQuery: vi.fn(),
hydratePreservedQuery: vi.fn(),
mergePreservedQueryIntoQuery: vi.fn()
}))
vi.mock(
'@/platform/navigation/preservedQueryManager',
() => preservedQueryMocks
)
const mockRouteQuery = vi.hoisted(() => ({
value: {} as Record<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('ignores empty param', async () => {
mockRouteQuery.value = { pricing: '' }
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
await loadPricingTableFromUrl()
expect(mockShowPricingTable).not.toHaveBeenCalled()
expect(mockRouterReplace).not.toHaveBeenCalled()
})
it('ignores non-string param', async () => {
mockRouteQuery.value = { pricing: fromAny<string, unknown>(['array']) }
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
await loadPricingTableFromUrl()
expect(mockShowPricingTable).not.toHaveBeenCalled()
})
it('opens the default tab for an unrecognized pricing value', async () => {
mockRouteQuery.value = { pricing: 'garbage' }
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
await loadPricingTableFromUrl()
expect(mockShowPricingTable).toHaveBeenCalledWith({
reason: 'deep_link',
planMode: undefined
})
})
it('strips and clears, then propagates a members-fetch failure', async () => {
mockRouteQuery.value = { pricing: '1' }
mockFetchMembers.mockRejectedValue(new Error('listMembers failed'))
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
await expect(loadPricingTableFromUrl()).rejects.toThrow(
'listMembers failed'
)
expect(mockShowPricingTable).not.toHaveBeenCalled()
expect(mockTrackSubscription).not.toHaveBeenCalled()
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
'pricing'
)
})
})

View File

@@ -0,0 +1,67 @@
import { useRoute, useRouter } from 'vue-router'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import {
clearPreservedQuery,
hydratePreservedQuery,
mergePreservedQueryIntoQuery
} from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
const NAMESPACE = PRESERVED_QUERY_NAMESPACES.PRICING
/**
* Opens the pricing table from a `?pricing=` deep link, to send pilot users
* straight to subscribe. Values: `1` (default tab), `team`, `personal`.
*
* Gated to the original owner (`canManageSubscriptionLifecycle`); a member or
* promoted owner is a silent no-op with the param stripped. Survives the login
* redirect via the preserved-query system, like the invite URL loader.
*/
export function usePricingTableUrlLoader() {
const route = useRoute()
const router = useRouter()
const subscriptionDialog = useSubscriptionDialog()
const workspaceStore = useTeamWorkspaceStore()
const { permissions } = useWorkspaceUI()
/** Reads `?pricing=`, strips it, and opens the table when the gate allows. */
async function loadPricingTableFromUrl() {
hydratePreservedQuery(NAMESPACE)
const query =
mergePreservedQueryIntoQuery(NAMESPACE, route.query) ?? route.query
const param = query.pricing
if (!param || typeof param !== 'string') return
// Strip the param (even for ineligible users) and write the clean URL in a
// single replace before any await, so a clean URL is guaranteed even if the
// replace rejects or the gate later denies the user.
const cleanQuery = { ...query }
delete cleanQuery.pricing
router.replace({ query: cleanQuery }).catch((error) => {
console.warn(
'[usePricingTableUrlLoader] Failed to clean URL params:',
error
)
})
clearPreservedQuery(NAMESPACE)
// Fetch members (no-ops for personal) so the original-owner self-row loads
// before the gate; fetchMembers awaits, ensureMembersLoaded can return early.
await workspaceStore.fetchMembers()
if (!permissions.value.canManageSubscriptionLifecycle) return
const planMode =
param === 'team' || param === 'personal' ? param : undefined
useTelemetry()?.trackSubscription('modal_opened', { reason: 'deep_link' })
subscriptionDialog.showPricingTable({ reason: 'deep_link', planMode })
}
return {
loadPricingTableFromUrl
}
}

View File

@@ -247,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 LegacyCreditsPanel).
* Use to determine which UI to show (SubscriptionPanel vs CreditsPanel).
*/
const isSubscriptionEnabled = (): boolean =>
Boolean(isCloud && window.__CONFIG__?.subscription_required)

View File

@@ -2,26 +2,21 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
// Mock dependencies
const mockFetchBalance = vi.fn()
const mockBillingFetchBalance = vi.fn()
const mockAuthFetchBalance = vi.fn()
const mockFetchStatus = vi.fn()
const mockShowTopUpCreditsDialog = vi.fn()
const mockExecute = vi.fn()
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: () => ({
fetchBalance: mockFetchBalance
})
}))
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
fetchStatus: mockFetchStatus
fetchBalance: mockAuthFetchBalance
})
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
fetchBalance: mockBillingFetchBalance,
fetchStatus: mockFetchStatus
})
}))
@@ -119,19 +114,19 @@ describe('useSubscriptionActions', () => {
})
describe('handleRefresh', () => {
it('should call both fetchBalance and fetchStatus', async () => {
it('should refresh balance and status through the billing facade', async () => {
const { handleRefresh } = useSubscriptionActions()
await handleRefresh()
expect(mockFetchBalance).toHaveBeenCalledOnce()
expect(mockBillingFetchBalance).toHaveBeenCalledOnce()
expect(mockFetchStatus).toHaveBeenCalledOnce()
expect(mockAuthFetchBalance).not.toHaveBeenCalled()
})
it('should handle errors gracefully', async () => {
mockFetchBalance.mockRejectedValueOnce(new Error('Fetch failed'))
mockBillingFetchBalance.mockRejectedValueOnce(new Error('Fetch failed'))
const { handleRefresh } = useSubscriptionActions()
// Should not throw
await expect(handleRefresh()).resolves.toBeUndefined()
})
})

View File

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

View File

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

View File

@@ -115,10 +115,8 @@ describe('useSubscriptionDialog', () => {
expect(mockShowLayoutDialog).toHaveBeenCalled()
})
it('uses the unified table (no onChooseTeam) when team workspaces are enabled', () => {
it('does not wire onChooseTeam on the unified table (personal subscribes directly)', () => {
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()
@@ -129,6 +127,21 @@ describe('useSubscriptionDialog', () => {
expect(props).not.toHaveProperty('onChooseTeam')
})
it('sizes the unified pricing dialog via the Reka contentClass, not the ignored PrimeVue style', () => {
mockTeamWorkspacesEnabled.value = true
mockIsInPersonalWorkspace.value = true
const { showPricingTable } = useSubscriptionDialog()
showPricingTable()
const { dialogComponentProps } = mockShowLayoutDialog.mock.calls[0][0]
// Reka (the default renderer) sizes via size/contentClass; a PrimeVue
// `style` width is silently ignored and collapses the wide table to the
// default md (576px) frame.
expect(dialogComponentProps).toHaveProperty('contentClass')
expect(dialogComponentProps).not.toHaveProperty('style')
})
it('defaults to the personal tab in a personal workspace', () => {
mockTeamWorkspacesEnabled.value = true
mockIsInPersonalWorkspace.value = true

View File

@@ -16,6 +16,7 @@ export type SubscriptionDialogReason =
| 'subscription_required'
| 'out_of_credits'
| 'top_up_blocked'
| 'deep_link'
export interface SubscriptionDialogOptions {
reason?: SubscriptionDialogReason
@@ -60,9 +61,13 @@ export const useSubscriptionDialog = () => {
),
props: { onClose: hide },
dialogComponentProps: {
renderer: 'reka',
contentClass:
'w-[min(360px,95vw)] max-w-[min(360px,95vw)] sm:max-w-[min(360px,95vw)] border-0 bg-transparent shadow-none'
style: 'width: min(360px, 95vw);',
pt: {
root: {
class: 'bg-transparent border-none rounded-none shadow-none'
},
content: { class: '!p-0 bg-transparent border-none shadow-none' }
}
}
})
return
@@ -129,18 +134,15 @@ export const useSubscriptionDialog = () => {
(workspaceStore.isInPersonalWorkspace ? 'personal' : 'team')
},
dialogComponentProps: {
// The dialog hugs its content so each step sizes itself: the pricing
// table stays wide/fixed (cards fill it, DES QA 2026-06-13) while the
// compact confirm/success steps shrink instead of floating in the big
// pricing modal. Sizes are set on the content root per checkoutStep.
style: 'max-width: 95vw; max-height: 90vh;',
pt: {
root: { class: 'rounded-2xl bg-transparent' },
content: {
class:
'!p-0 rounded-2xl border border-border-default bg-secondary-background shadow-[0_25px_80px_rgba(5,6,12,0.45)]'
}
}
// Reka (the default renderer) sizes via size/contentClass; a PrimeVue
// `style` width is ignored here and collapses the table to the default
// `md` frame. `w-fit` lets each step hug its content — the pricing
// table fills its 1280px content while the compact confirm/success
// steps shrink (the content root sets its own width per checkoutStep).
renderer: 'reka',
size: 'full',
contentClass:
'w-fit max-w-[min(1280px,95vw)] sm:max-w-[min(1280px,95vw)] max-h-[90vh] rounded-2xl border border-border-default bg-secondary-background shadow-[0_25px_80px_rgba(5,6,12,0.45)]'
}
})
return
@@ -180,10 +182,16 @@ export const useSubscriptionDialog = () => {
}
},
dialogComponentProps: {
renderer: 'reka',
size: 'full',
contentClass:
'w-[min(640px,95vw)] max-w-[min(640px,95vw)] sm:max-w-[min(640px,95vw)] overflow-hidden rounded-2xl border-border-default bg-base-background/60 shadow-[0_25px_80px_rgba(5,6,12,0.45)] backdrop-blur-md'
style: 'width: min(640px, 95vw);',
pt: {
root: {
class: 'rounded-2xl bg-transparent'
},
content: {
class:
'!p-0 rounded-2xl border border-border-default bg-base-background/60 backdrop-blur-md shadow-[0_25px_80px_rgba(5,6,12,0.45)]'
}
}
}
})
return

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import {
TEAM_PLAN_SLUG_BY_CYCLE,
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('TEAM_PLAN_SLUG_BY_CYCLE', () => {
it('maps the billing cycle to the per-credit team plan slug', () => {
expect(TEAM_PLAN_SLUG_BY_CYCLE.monthly).toBe('team_per_credit_monthly')
expect(TEAM_PLAN_SLUG_BY_CYCLE.yearly).toBe('team_per_credit_annual')
})
})

View File

@@ -1,4 +1,7 @@
export interface CreditStop {
/** Backend stop identifier (e.g. "team_700"), sent on subscribe. Absent for
* the hardcoded fallback stops until the API contract reaches prod. */
id?: string
/** Monthly subscription price in USD (pre-discount). */
usd: number
/** Monthly credit grant at this stop. */
@@ -17,6 +20,9 @@ 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. Absent for
* the hardcoded fallback stops until the API contract reaches prod. */
id?: string
/** Pre-discount monthly price in USD (the struck-through list price). */
usd: number
/** Monthly credit grant at this stop. */
@@ -49,6 +55,41 @@ export const TEAM_PLAN_CREDIT_STOPS: readonly CreditStop[] = [
/** Default stop per DES-197: index 2 = $700 / 147,700 credits. */
export const DEFAULT_TEAM_PLAN_STOP_INDEX = 2
/** Plan slugs for the per-credit Team plan, keyed by billing cycle. */
export const TEAM_PLAN_SLUG_BY_CYCLE = {
monthly: 'team_per_credit_monthly',
yearly: 'team_per_credit_annual'
} as const
/**
* 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 stop's list `usd`, applying the billing-cycle
* discount (yearly = full `discountPercentYearly`; monthly halves it). Shared by

View File

@@ -0,0 +1,30 @@
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
})
})
})

View File

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

View File

@@ -0,0 +1,83 @@
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',
billingCycle: 'yearly'
})
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',
expect.objectContaining({
teamCreditStopId: 'team_1400',
billingCycle: 'monthly'
})
)
expect(assignedHref).toBe('/')
})
it('does nothing off cloud', async () => {
mockIsCloud.value = false
await performTeamSubscriptionCheckout('team_700', 'yearly')
expect(mockSubscribe).not.toHaveBeenCalled()
expect(assignedHref).toBeUndefined()
})
})

View File

@@ -0,0 +1,48 @@
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import { TEAM_PLAN_SLUG_BY_CYCLE } 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 response = await workspaceApi.subscribe(
TEAM_PLAN_SLUG_BY_CYCLE[billingCycle],
{
returnUrl: `${getComfyPlatformBaseUrl()}/payment/success`,
cancelUrl: `${getComfyPlatformBaseUrl()}/payment/failed`,
teamCreditStopId,
billingCycle
}
)
if (
response.status === 'needs_payment_method' &&
response.payment_method_url
) {
globalThis.location.href = response.payment_method_url
return
}
globalThis.location.href = '/'
}

View File

@@ -4,14 +4,13 @@ import {
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
type BenefitType = 'metric' | 'feature' | 'icon'
type BenefitType = 'metric' | 'feature'
export interface TierBenefit {
key: string
type: BenefitType
label: string
value?: string
icon?: string
}
export function getCommonTierBenefits(

View File

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

View File

@@ -59,3 +59,10 @@ export const cachedTeamWorkspacesEnabled = useStorage<boolean | undefined>(
'team_workspaces_enabled' satisfies `${ServerFeatureFlag.TEAM_WORKSPACES_ENABLED}`,
undefined
)
export const cachedPersonalWorkspaceBillingReady = useStorage<
boolean | undefined
>(
'personal_workspace_billing_ready' satisfies `${ServerFeatureFlag.PERSONAL_WORKSPACE_BILLING_READY}`,
undefined
)

View File

@@ -102,6 +102,7 @@ export type RemoteConfig = {
onboarding_survey?: OnboardingSurvey
linear_toggle_enabled?: boolean
team_workspaces_enabled?: boolean
personal_workspace_billing_ready?: boolean
user_secrets_enabled?: boolean
node_library_essentials_enabled?: boolean
free_tier_credits?: number

View File

@@ -90,7 +90,7 @@ import CurrentUserMessage from '@/components/dialog/content/setting/CurrentUserM
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import NavItem from '@/components/widget/nav/NavItem.vue'
import NavTitle from '@/components/widget/nav/NavTitle.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import ColorPaletteMessage from '@/platform/settings/components/ColorPaletteMessage.vue'
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
@@ -130,7 +130,7 @@ const {
getSearchResults
} = useSettingSearch()
const authActions = useAuthActions()
const { fetchBalance } = useBillingContext()
const navRef = ref<HTMLElement | null>(null)
const activeCategoryKey = ref<string | null>(defaultCategory.value?.key ?? null)
@@ -238,7 +238,7 @@ watch(activeCategoryKey, (newKey, oldKey) => {
activeCategoryKey.value = oldKey
}
if (newKey === 'credits') {
void authActions.fetchBalance()
void fetchBalance()
}
if (newKey) {
void nextTick(() => {

View File

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

View File

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

View File

@@ -464,6 +464,11 @@ 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.
@@ -484,6 +489,7 @@ export interface TelemetryProvider {
metadata?: SubscriptionSuccessMetadata
): void
trackMonthlySubscriptionCancelled?(): void
trackWorkspaceInviteSent?(metadata: WorkspaceInviteMetadata): void
trackAddApiCreditButtonClicked?(): void
trackApiCreditTopupButtonPurchaseClicked?(amount: number): void
trackApiCreditTopupSucceeded?(): void

View File

@@ -334,18 +334,42 @@ describe('workspaceApi', () => {
const data = { billing_op_id: 'op-1', status: 'subscribed' }
mockAxiosInstance.post.mockResolvedValue({ data })
const result = await workspaceApi.subscribe(
'pro-monthly',
'https://return.url',
'https://cancel.url'
)
const result = await workspaceApi.subscribe('pro-monthly', {
returnUrl: 'https://return.url',
cancelUrl: 'https://cancel.url'
})
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
'/api/billing/subscribe',
{
plan_slug: 'pro-monthly',
return_url: 'https://return.url',
cancel_url: 'https://cancel.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'
},
{ headers: AUTH_HEADER }
)

View File

@@ -151,11 +151,22 @@ interface PreviewSubscribeRequest {
plan_slug: string
}
type SubscribeBillingCycle = 'monthly' | 'yearly'
interface SubscribeRequest {
plan_slug: string
idempotency_key?: string
return_url?: string
cancel_url?: string
team_credit_stop_id?: string
billing_cycle?: SubscribeBillingCycle
}
export interface SubscribeOptions {
returnUrl?: string
cancelUrl?: string
teamCreditStopId?: string
billingCycle?: SubscribeBillingCycle
}
type SubscribeStatus = 'subscribed' | 'needs_payment_method' | 'pending_payment'
@@ -457,6 +468,24 @@ 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
@@ -602,8 +631,7 @@ export const workspaceApi = {
*/
async subscribe(
planSlug: string,
returnUrl?: string,
cancelUrl?: string
options: SubscribeOptions = {}
): Promise<SubscribeResponse> {
const headers = await getAuthHeaderOrThrow()
try {
@@ -611,8 +639,10 @@ export const workspaceApi = {
api.apiURL('/billing/subscribe'),
{
plan_slug: planSlug,
return_url: returnUrl,
cancel_url: cancelUrl
return_url: options.returnUrl,
cancel_url: options.cancelUrl,
team_credit_stop_id: options.teamCreditStopId,
billing_cycle: options.billingCycle
} satisfies SubscribeRequest,
{ headers }
)

View File

@@ -0,0 +1,175 @@
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
})
}))
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 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, 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).toBeUndefined()
expect(mockTrackInviteSent).toHaveBeenCalledWith({
source: 'post_upgrade_success',
count: 1
})
})
it('caps the number of chips at maxSeats', async () => {
const { user } = renderForm({ maxSeats: 2 })
await user.type(emailInput(), 'a@b.com,b@b.com,c@b.com{Enter}')
expect(screen.getByText('a@b.com')).toBeInTheDocument()
expect(screen.getByText('b@b.com')).toBeInTheDocument()
expect(screen.queryByText('c@b.com')).not.toBeInTheDocument()
expect(
screen.getByText('workspacePanel.inviteMemberDialog.seatLimitReached')
).toBeInTheDocument()
})
it('emits cancel when a cancel label is provided', async () => {
const { user, emitted } = renderForm({ cancelLabel: 'Cancel' })
await user.click(screen.getByRole('button', { name: 'Cancel' }))
expect(emitted().cancel).toBeTruthy()
expect(mockCreateInvite).not.toHaveBeenCalled()
})
it('hides the built-in submit row when showSubmit is false', () => {
renderForm({ showSubmit: false })
expect(
screen.queryByRole('button', { name: 'Send invites' })
).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,181 @@
<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',
!EMAIL_REGEX.test(email) && 'bg-danger/20 text-danger'
)
"
>
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput
auto-focus
class="min-w-0 text-sm"
:placeholder="emails.length === 0 ? placeholder : undefined"
/>
</TagsInput>
<p v-if="invalidEmails.length > 0" class="text-danger m-0 text-xs">
{{
$t(
'workspacePanel.inviteMemberDialog.invalidEmailCount',
invalidEmails.length
)
}}
</p>
<p v-else-if="isSeatLimitReached" 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"
@click="onSubmit"
>
{{ submitLabel }}
</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 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 { cn } from '@comfyorg/tailwind-utils'
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const EMAIL_DELIMITER = /[,\s]+/
const {
submitLabel,
placeholder,
source,
cancelLabel,
maxSeats = Number.POSITIVE_INFINITY,
showSubmit = true
} = 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
}>()
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 invalidEmails = computed(() =>
emails.value.filter((email) => !EMAIL_REGEX.test(email))
)
const isSeatLimitReached = computed(() => emails.value.length >= maxSeats)
const canSubmit = computed(
() =>
emails.value.length > 0 &&
emails.value.length <= maxSeats &&
invalidEmails.value.length === 0
)
function normalizeEmail(value: string) {
return value.trim().toLowerCase()
}
function onEmailsUpdate(value: string[]) {
const nonEmpty = value.filter((email) => email.length > 0)
emails.value =
nonEmpty.length > maxSeats ? nonEmpty.slice(0, maxSeats) : nonEmpty
}
async function onSubmit() {
if (!canSubmit.value || loading.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'
)
const invited = submitted.filter((email) => !failedEmails.includes(email))
if (invited.length > 0) {
telemetry?.trackWorkspaceInviteSent({ source, count: invited.length })
}
if (failedEmails.length === 0) {
emit('submitted', submitted)
return
}
emails.value = failedEmails
toast.add({
severity: 'error',
summary: t(
'workspacePanel.inviteMemberDialog.failedCount',
failedEmails.length
)
})
} finally {
loading.value = false
}
}
defineExpose({ submit: onSubmit, canSubmit, loading })
</script>

View File

@@ -2,8 +2,42 @@ 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,
@@ -42,6 +76,68 @@ describe('SubscriptionAddPaymentPreviewWorkspace', () => {
expect(screen.getByText('$380.00')).toBeTruthy()
})
it('shows the monthly-equivalent price and annual total for a yearly preview', () => {
render(SubscriptionAddPaymentPreviewWorkspace, {
props: {
tierKey: 'creator',
billingCycle: 'yearly',
previewData: previewFixture('ANNUAL', 33_600)
},
global: globalOptions
})
expect(screen.getByText('subscription.usdPerMonth')).toBeTruthy()
expect(screen.getByText('$28')).toBeTruthy()
expect(screen.getByText('subscription.billedYearly')).toBeTruthy()
expect(screen.getByText('$336.00')).toBeTruthy()
expect(
screen.getByText('subscription.preview.eachYearCreditsRefill')
).toBeTruthy()
expect(screen.getByText('88,800')).toBeTruthy()
})
it('divides the yearly price by twelve in the fallback path', () => {
render(SubscriptionAddPaymentPreviewWorkspace, {
props: { tierKey: 'creator', billingCycle: 'yearly' },
global: globalOptions
})
expect(screen.getByText('$28')).toBeTruthy()
expect(screen.getByText('subscription.billedYearly')).toBeTruthy()
expect(screen.getByText('$336.00')).toBeTruthy()
})
it('omits the billed-yearly note for a monthly subscription', () => {
render(SubscriptionAddPaymentPreviewWorkspace, {
props: {
tierKey: 'creator',
billingCycle: 'monthly',
previewData: previewFixture('MONTHLY', 3_500)
},
global: globalOptions
})
expect(screen.getByText('$35')).toBeTruthy()
expect(screen.getByText('subscription.billedMonthly')).toBeTruthy()
expect(screen.queryByText('subscription.billedYearly')).toBeNull()
expect(
screen.getByText('subscription.preview.eachMonthCreditsRefill')
).toBeTruthy()
expect(
screen.queryByText('subscription.preview.eachYearCreditsRefill')
).toBeNull()
})
it('shows the annual total for a yearly team plan', () => {
render(SubscriptionAddPaymentPreviewWorkspace, {
props: {
billingCycle: 'yearly',
teamPlan: { usd: 400, credits: 84_400, discountedUsd: 380 }
},
global: globalOptions
})
expect(screen.getByText('$380')).toBeTruthy()
expect(screen.getByText('subscription.billedYearly')).toBeTruthy()
expect(screen.getByText('$4560.00')).toBeTruthy()
})
it('emits addCreditCard from the team confirm CTA', async () => {
const { emitted } = render(SubscriptionAddPaymentPreviewWorkspace, {
props: { teamPlan: { usd: 400, credits: 84_400, discountedUsd: 380 } },

View File

@@ -19,13 +19,13 @@
{{ $t('subscription.usdPerMonth') }}
</span>
</div>
<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">
{{
isYearly
? $t('subscription.billedYearly', { total: annualTotalFormatted })
: $t('subscription.billedMonthly')
}}
</span>
<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('subscription.preview.eachMonthCreditsRefill') }}
{{ $t(creditsRefillLabelKey) }}
</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">
{{ displayCredits }}
{{ refillCredits }}
</span>
</div>
</div>
@@ -138,7 +138,7 @@
<!-- Add Credit Card Button -->
<Button
variant="secondary"
variant="tertiary"
size="lg"
class="w-full rounded-lg"
:loading="isLoading"
@@ -210,16 +210,41 @@ const tierName = computed(() =>
: t(`subscription.tiers.${tierKey}.name`)
)
const isYearly = computed(() =>
previewData?.new_plan
? previewData.new_plan.duration === 'ANNUAL'
: billingCycle === 'yearly'
)
const displayPrice = computed(() => {
if (teamPlan) return teamPlan.discountedUsd
if (previewData?.new_plan) {
return (previewData.new_plan.price_cents / 100).toFixed(0)
const cents = previewData.new_plan.price_cents
return ((isYearly.value ? cents / 12 : cents) / 100).toFixed(0)
}
return tierKey ? getTierPrice(tierKey, billingCycle === 'yearly') : 0
return tierKey ? getTierPrice(tierKey, isYearly.value) : 0
})
const displayCredits = computed(() =>
n(teamPlan ? teamPlan.credits : tierKey ? (getTierCredits(tierKey) ?? 0) : 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 teamPerks = computed(() => [
@@ -235,16 +260,18 @@ const hasCustomLoRAs = computed(() =>
const maxDuration = computed(() => t(`subscription.maxDuration.${tierKey}`))
const totalDueToday = computed(() => {
if (teamPlan) return teamPlan.discountedUsd.toFixed(2)
if (teamPlan) {
const total = isYearly.value
? teamPlan.discountedUsd * 12
: teamPlan.discountedUsd
return total.toFixed(2)
}
if (previewData) {
return (previewData.cost_today_cents / 100).toFixed(2)
}
if (!tierKey) return '0.00'
const priceValue = getTierPrice(tierKey, billingCycle === 'yearly')
if (billingCycle === 'yearly') {
return (priceValue * 12).toFixed(2)
}
return priceValue.toFixed(2)
const priceValue = getTierPrice(tierKey, isYearly.value)
return (isYearly.value ? priceValue * 12 : priceValue).toFixed(2)
})
const nextPaymentDate = computed(() => {

View File

@@ -10,8 +10,10 @@ type PreviewPlanInfo = PreviewSubscribeResponse['new_plan']
/**
* Checkout steps of the unified subscription dialog (FE-934): the
* "Confirm your payment" / "Confirm your plan change" preview screens and the
* "You're all set" success screen. Driven by props (no API in Storybook).
* "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.
*/
const meta: Meta = {
title: 'Components/SubscriptionCheckoutSteps',
@@ -21,38 +23,49 @@ const meta: Meta = {
export default meta
type Story = StoryObj
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 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 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 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 shell =
'<div class="mx-auto flex h-[680px] w-[460px] flex-col rounded-2xl border border-border-default bg-secondary-background p-12">'
/** New subscription — "Confirm your payment" (AddPayment preview). */
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). */
export const ConfirmNewSubscription: Story = {
render: () => ({
components: { SubscriptionAddPaymentPreviewWorkspace },
@@ -60,13 +73,13 @@ export const ConfirmNewSubscription: Story = {
previewData: {
allowed: true,
transition_type: 'new_subscription',
effective_at: '2026-07-10T00:00:00Z',
effective_at: TODAY,
is_immediate: true,
cost_today_cents: 2800,
cost_next_period_cents: 2800,
credits_today_cents: 740000,
credits_next_period_cents: 740000,
new_plan: creatorPlan
cost_today_cents: 33_600,
cost_next_period_cents: 33_600,
credits_today_cents: 0,
credits_next_period_cents: 0,
new_plan: creatorAnnual
} satisfies PreviewSubscribeResponse
}),
template: `${shell}<SubscriptionAddPaymentPreviewWorkspace tier-key="creator" billing-cycle="yearly" :preview-data="previewData" /></div>`
@@ -77,32 +90,68 @@ export const ConfirmNewSubscription: Story = {
export const ConfirmTeamSubscription: Story = {
render: () => ({
components: { SubscriptionAddPaymentPreviewWorkspace },
data: () => ({ teamPlan: { usd: 400, credits: 84_400 } }),
template: `${shell}<SubscriptionAddPaymentPreviewWorkspace :team-plan="teamPlan" /></div>`
data: () => ({
teamPlan: { usd: 700, credits: 147_700, discountedUsd: 630 }
}),
template: `${shell}<SubscriptionAddPaymentPreviewWorkspace :team-plan="teamPlan" billing-cycle="yearly" /></div>`
})
}
/** Plan change — "Confirm your plan change" (Transition preview, Pro → Creator). */
export const ConfirmPlanChange: Story = {
render: () => ({
components: { SubscriptionTransitionPreviewWorkspace },
data: () => ({
previewData: {
allowed: true,
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: 740000,
current_plan: proPlan,
new_plan: creatorPlan
} satisfies PreviewSubscribeResponse
}),
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 = {
@@ -112,15 +161,27 @@ export const SuccessAllSet: Story = {
previewData: {
allowed: true,
transition_type: 'new_subscription',
effective_at: '2026-07-10T00:00:00Z',
effective_at: TODAY,
is_immediate: true,
cost_today_cents: 2800,
cost_next_period_cents: 2800,
credits_today_cents: 740000,
credits_next_period_cents: 740000,
new_plan: creatorPlan
cost_today_cents: 33_600,
cost_next_period_cents: 33_600,
credits_today_cents: 0,
credits_next_period_cents: 0,
new_plan: creatorAnnual
} 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: () => ({ previewData: { new_plan: creatorMonthly } }),
template: `${shell}<SubscriptionSuccessWorkspace tier-key="creator" is-team :preview-data="previewData" /></div>`
})
}

View File

@@ -0,0 +1,540 @@
import { createTestingPinia } from '@pinia/testing'
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 type { SubscriptionInfo } from '@/composables/billing/types'
import enMessages from '@/locales/en/main.json'
import type {
CurrentTeamCreditStop,
TeamCreditStops
} from '@/platform/workspace/api/workspaceApi'
import SubscriptionPanelContentWorkspace from './SubscriptionPanelContentWorkspace.vue'
const RENEWAL_DATE_ISO = '2026-06-20T12:00:00Z'
const END_DATE_ISO = '2026-01-20T12:00:00Z'
function formatPanelDate(isoDate: string) {
return new Date(isoDate).toLocaleDateString('en', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
}
// The panel displays the subscribed credit stop's per-month price; monthly and
// yearly stops are both per-month figures.
const teamCreditStops: TeamCreditStops = {
default_stop_index: 1,
stops: [
{
id: 'team_700',
credits: 147700,
monthly: { list_price_cents: 70000, price_cents: 66500 },
yearly: { list_price_cents: 70000, price_cents: 63000 }
},
{
id: 'team_2500',
credits: 527500,
monthly: { list_price_cents: 250000, price_cents: 225000 },
yearly: { list_price_cents: 250000, price_cents: 200000 }
}
]
}
const mockSubscriptionStatus = ref<'active' | 'canceled'>('active')
const mockSubscriptionDuration = ref<'MONTHLY' | 'ANNUAL'>('MONTHLY')
const mockHasSubscription = ref(true)
const mockIsActiveSubscription = ref(true)
const mockIsInPersonalWorkspace = ref(false)
const mockIsWorkspaceSubscribed = ref(true)
// canManageSubscription = any owner (billing, change plan);
// canManageSubscriptionLifecycle = original owner only (cancel, resubscribe).
const mockCanManageSubscription = ref(true)
const mockCanManageSubscriptionLifecycle = ref(true)
const mockIsCurrentUserOriginalOwner = ref(true)
const mockMembers = ref([{ id: 'member-1' }, { id: 'member-2' }])
const mockTeamCreditStops = ref<TeamCreditStops | null>(teamCreditStops)
const mockCurrentTeamCreditStop = ref<CurrentTeamCreditStop | null>({
id: 'team_700',
credits_monthly: 147700,
stop_usd: 700
})
const mockManageSubscription = vi.fn()
const mockShowSubscriptionDialog = vi.fn()
const mockResubscribe = vi.fn()
const mockShowLeaveWorkspaceDialog = vi.fn()
const mockShowCancelSubscriptionDialog = vi.fn()
const mockShowEditWorkspaceDialog = vi.fn()
const mockShowDeleteWorkspaceDialog = vi.fn()
type MenuUiConfig = {
showEditWorkspaceMenuItem: boolean
workspaceMenuAction: 'leave' | 'delete' | null
workspaceMenuDisabledTooltip: string | null
}
const ownerUiConfig: MenuUiConfig = {
showEditWorkspaceMenuItem: true,
workspaceMenuAction: 'delete',
workspaceMenuDisabledTooltip:
'workspacePanel.menu.deleteWorkspaceDisabledTooltip'
}
const memberUiConfig: MenuUiConfig = {
showEditWorkspaceMenuItem: false,
workspaceMenuAction: 'leave',
workspaceMenuDisabledTooltip: null
}
const personalUiConfig: MenuUiConfig = {
showEditWorkspaceMenuItem: true,
workspaceMenuAction: 'delete',
workspaceMenuDisabledTooltip:
'workspacePanel.menu.deleteWorkspaceDisabledTooltip'
}
const mockUiConfig = ref<MenuUiConfig>(ownerUiConfig)
const mockSubscription = computed<SubscriptionInfo | null>(() =>
mockHasSubscription.value
? {
isActive: true,
tier: 'PRO',
duration: mockSubscriptionDuration.value,
planSlug: 'team-monthly',
renewalDate: RENEWAL_DATE_ISO,
endDate: END_DATE_ISO,
isCancelled: mockSubscriptionStatus.value === 'canceled',
hasFunds: true
}
: null
)
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: computed(() => mockIsActiveSubscription.value),
isFreeTier: computed(() => false),
subscription: mockSubscription,
teamCreditStops: mockTeamCreditStops,
currentTeamCreditStop: mockCurrentTeamCreditStop,
showSubscriptionDialog: mockShowSubscriptionDialog,
manageSubscription: mockManageSubscription,
resubscribe: mockResubscribe
})
}))
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
isInPersonalWorkspace: mockIsInPersonalWorkspace,
isWorkspaceSubscribed: mockIsWorkspaceSubscribed,
members: mockMembers,
isCurrentUserOriginalOwner: mockIsCurrentUserOriginalOwner
})
}))
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
permissions: computed(() => ({
canManageSubscription: mockCanManageSubscription.value,
canManageSubscriptionLifecycle: mockCanManageSubscriptionLifecycle.value
})),
uiConfig: computed(() => mockUiConfig.value)
})
}))
vi.mock('@/platform/workspace/stores/billingOperationStore', () => ({
useBillingOperationStore: () => ({ isSettingUp: false })
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showCancelSubscriptionDialog: mockShowCancelSubscriptionDialog,
showLeaveWorkspaceDialog: mockShowLeaveWorkspaceDialog,
showEditWorkspaceDialog: mockShowEditWorkspaceDialog,
showDeleteWorkspaceDialog: mockShowDeleteWorkspaceDialog
})
}))
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
() => ({
useSubscriptionDialog: () => ({ showPricingTable: vi.fn() })
})
)
vi.mock('primevue/usetoast', () => ({
useToast: () => ({ add: vi.fn() })
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
const CreditsTileStub = {
props: ['zeroState'],
template:
'<div data-testid="credits-tile" :data-zero-state="String(zeroState)" />'
}
const ButtonStub = {
template:
'<button v-bind="$attrs" :disabled="loading" @click="$emit(\'click\', $event)"><slot /></button>',
props: ['variant', 'size', 'loading'],
emits: ['click']
}
const SubscriptionFooterLinksStub = {
template: '<div data-testid="subscription-footer-links" />'
}
const DropdownMenuStub = {
props: ['entries'],
template:
'<div data-testid="plan-menu"><slot name="button" /><button v-for="item in (entries || []).filter((e) => !e.separator)" :key="item.label" type="button" :disabled="item.disabled" @click="item.command?.({})">{{ item.label }}</button></div>'
}
function renderComponent() {
return render(SubscriptionPanelContentWorkspace, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
directives: { tooltip: {} },
stubs: {
CreditsTile: CreditsTileStub,
Button: ButtonStub,
SubscriptionFooterLinks: SubscriptionFooterLinksStub,
StatusBadge: true,
DropdownMenu: DropdownMenuStub
}
}
})
}
describe('SubscriptionPanelContentWorkspace', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSubscriptionStatus.value = 'active'
mockHasSubscription.value = true
mockIsActiveSubscription.value = true
mockIsInPersonalWorkspace.value = false
mockIsWorkspaceSubscribed.value = true
mockCanManageSubscription.value = true
mockCanManageSubscriptionLifecycle.value = true
mockIsCurrentUserOriginalOwner.value = true
mockUiConfig.value = ownerUiConfig
mockMembers.value = [{ id: 'member-1' }, { id: 'member-2' }]
mockSubscriptionDuration.value = 'MONTHLY'
mockTeamCreditStops.value = teamCreditStops
mockCurrentTeamCreditStop.value = {
id: 'team_700',
credits_monthly: 147700,
stop_usd: 700
}
})
it('renders the subscribed credit stop price and renewal subtitle', () => {
renderComponent()
expect(screen.getByText('Team')).toBeInTheDocument()
// Monthly subscription on team_700 -> monthly.price_cents 66500 -> $665.
expect(screen.getByText('$665')).toBeInTheDocument()
expect(screen.getByText('USD / mo')).toBeInTheDocument()
expect(screen.queryByText('USD / mo / member')).not.toBeInTheDocument()
expect(
screen.getByText(`Renews on ${formatPanelDate(RENEWAL_DATE_ISO)}`)
).toBeInTheDocument()
expect(screen.getByTestId('subscription-footer-links')).toBeInTheDocument()
})
it('uses the yearly stop price for an annual subscription, still shown per month', () => {
mockSubscriptionDuration.value = 'ANNUAL'
mockCurrentTeamCreditStop.value = {
id: 'team_2500',
credits_monthly: 527500,
stop_usd: 2500
}
renderComponent()
// team_2500 yearly.price_cents 200000 -> $2000, labelled per month.
expect(screen.getByText('$2000')).toBeInTheDocument()
expect(screen.getByText('USD / mo')).toBeInTheDocument()
})
it('falls back to the per-member tier price until stops resolve', () => {
mockTeamCreditStops.value = null
mockCurrentTeamCreditStop.value = null
renderComponent()
expect(screen.getByText('$100')).toBeInTheDocument()
expect(screen.getByText('USD / mo / member')).toBeInTheDocument()
})
it('wires Manage billing and Change plan actions for subscription managers', async () => {
const user = userEvent.setup()
renderComponent()
await user.click(screen.getByRole('button', { name: 'Manage billing' }))
expect(mockManageSubscription).toHaveBeenCalledOnce()
await user.click(screen.getByRole('button', { name: 'Change plan' }))
expect(mockShowSubscriptionDialog).toHaveBeenCalledOnce()
})
it('hides plan management actions from members without permission', () => {
mockCanManageSubscription.value = false
mockCanManageSubscriptionLifecycle.value = false
mockIsCurrentUserOriginalOwner.value = false
mockUiConfig.value = memberUiConfig
renderComponent()
expect(
screen.queryByRole('button', { name: 'Manage billing' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Change plan' })
).not.toBeInTheDocument()
expect(screen.getByText('Invite members')).toBeInTheDocument()
})
it('reactivates a cancelled plan as the original owner, keeping Manage billing', async () => {
const user = userEvent.setup()
mockSubscriptionStatus.value = 'canceled'
renderComponent()
expect(
screen.getByText(`Ends on ${formatPanelDate(END_DATE_ISO)}`)
).toBeInTheDocument()
expect(
screen.queryByText(`Renews on ${formatPanelDate(RENEWAL_DATE_ISO)}`)
).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Manage billing' })
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Change plan' })
).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Reactivate plan' }))
expect(mockResubscribe).toHaveBeenCalledOnce()
})
it('hides Reactivate plan from a promoted owner on a cancelled plan', () => {
mockSubscriptionStatus.value = 'canceled'
mockCanManageSubscriptionLifecycle.value = false
mockIsCurrentUserOriginalOwner.value = false
renderComponent()
expect(
screen.getByRole('button', { name: 'Manage billing' })
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Reactivate plan' })
).not.toBeInTheDocument()
})
it('shows the zero-state subscribe prompt to unsubscribed team owners', () => {
mockIsActiveSubscription.value = false
mockIsWorkspaceSubscribed.value = false
mockHasSubscription.value = false
renderComponent()
expect(
screen.getByText('This workspace is not on a subscription')
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Subscribe Now' })
).toBeInTheDocument()
expect(screen.getByTestId('credits-tile')).toHaveAttribute(
'data-zero-state',
'true'
)
})
it('shows the zero-state contact-owner view to unsubscribed members', () => {
mockIsActiveSubscription.value = false
mockIsWorkspaceSubscribed.value = false
mockHasSubscription.value = false
mockCanManageSubscription.value = false
renderComponent()
expect(
screen.getByText('Contact the workspace owner to subscribe')
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Subscribe Now' })
).not.toBeInTheDocument()
expect(screen.getByTestId('credits-tile')).toHaveAttribute(
'data-zero-state',
'true'
)
})
it('renders the Free plan header with Subscribe CTA for unsubscribed personal workspaces', async () => {
const user = userEvent.setup()
mockIsInPersonalWorkspace.value = true
mockIsActiveSubscription.value = false
mockHasSubscription.value = false
mockIsWorkspaceSubscribed.value = false
mockUiConfig.value = personalUiConfig
renderComponent()
expect(screen.getByText('Free')).toBeInTheDocument()
expect(screen.getByText('$0')).toBeInTheDocument()
expect(screen.getByText('USD / mo')).toBeInTheDocument()
expect(screen.getByText("What's included:")).toBeInTheDocument()
expect(screen.getByText('10 min max runtime')).toBeInTheDocument()
expect(
screen.queryByText('RTX 6000 Pro (96GB VRAM)')
).not.toBeInTheDocument()
expect(screen.getByTestId('credits-tile')).toHaveAttribute(
'data-zero-state',
'false'
)
await user.click(screen.getByRole('button', { name: 'Subscribe' }))
expect(mockShowSubscriptionDialog).toHaveBeenCalledOnce()
})
it('lets a Free personal workspace only rename itself (no Cancel or Delete)', async () => {
const user = userEvent.setup()
mockIsInPersonalWorkspace.value = true
mockIsActiveSubscription.value = false
mockHasSubscription.value = false
mockIsWorkspaceSubscribed.value = false
mockUiConfig.value = personalUiConfig
renderComponent()
expect(
screen.queryByRole('button', { name: 'Cancel plan' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Delete Workspace' })
).not.toBeInTheDocument()
await user.click(
screen.getByRole('button', { name: 'Edit workspace details' })
)
expect(mockShowEditWorkspaceDialog).toHaveBeenCalledOnce()
})
it('offers a subscribed personal workspace Edit, Cancel plan, and a locked Delete', () => {
mockIsInPersonalWorkspace.value = true
mockIsActiveSubscription.value = true
mockHasSubscription.value = true
mockIsWorkspaceSubscribed.value = false
mockUiConfig.value = personalUiConfig
renderComponent()
expect(
screen.getByRole('button', { name: 'Edit workspace details' })
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Cancel plan' })
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Delete Workspace' })
).toBeDisabled()
expect(
screen.queryByRole('button', { name: 'Leave Workspace' })
).not.toBeInTheDocument()
})
it('lists the four team perks under the Pro-inclusive heading', () => {
renderComponent()
expect(
screen.getByText(/Your plan includes everything in/)
).toBeInTheDocument()
expect(screen.getByText('Pro')).toBeInTheDocument()
expect(screen.getByText('Invite members')).toBeInTheDocument()
expect(
screen.getByText('Members can run workflows concurrently')
).toBeInTheDocument()
expect(
screen.getByText('Shared credit pool for all members')
).toBeInTheDocument()
expect(screen.getByText('Role-based permissions')).toBeInTheDocument()
expect(
screen.queryByText('RTX 6000 Pro (96GB VRAM)')
).not.toBeInTheDocument()
})
it('offers members only Leave Workspace in the menu', () => {
mockCanManageSubscription.value = false
mockCanManageSubscriptionLifecycle.value = false
mockIsCurrentUserOriginalOwner.value = false
mockUiConfig.value = memberUiConfig
renderComponent()
expect(
screen.getByRole('button', { name: 'Leave Workspace' })
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Cancel plan' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Edit workspace details' })
).not.toBeInTheDocument()
})
it('opens the leave-workspace dialog from a member menu', async () => {
const user = userEvent.setup()
mockCanManageSubscription.value = false
mockCanManageSubscriptionLifecycle.value = false
mockIsCurrentUserOriginalOwner.value = false
mockUiConfig.value = memberUiConfig
renderComponent()
await user.click(screen.getByRole('button', { name: 'Leave Workspace' }))
expect(mockShowLeaveWorkspaceDialog).toHaveBeenCalledOnce()
})
it('offers a promoted owner Edit and Leave (no Cancel or Delete)', () => {
mockCanManageSubscriptionLifecycle.value = false
mockIsCurrentUserOriginalOwner.value = false
renderComponent()
expect(
screen.getByRole('button', { name: 'Edit workspace details' })
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Leave Workspace' })
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Cancel plan' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Delete Workspace' })
).not.toBeInTheDocument()
})
it('offers the original owner Edit, Cancel plan, and a subscription-locked Delete', async () => {
const user = userEvent.setup()
renderComponent()
expect(
screen.getByRole('button', { name: 'Edit workspace details' })
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Delete Workspace' })
).toBeDisabled()
expect(
screen.queryByRole('button', { name: 'Leave Workspace' })
).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Cancel plan' }))
expect(mockShowCancelSubscriptionDialog).toHaveBeenCalledOnce()
})
it('enables Delete for the original owner once the plan is cancelled', () => {
mockSubscriptionStatus.value = 'canceled'
renderComponent()
expect(
screen.getByRole('button', { name: 'Delete Workspace' })
).toBeEnabled()
})
})

View File

@@ -1,5 +1,5 @@
<template>
<div class="grow overflow-auto pt-6">
<div class="flex grow flex-col overflow-auto pt-6">
<!-- Loading state while subscription is being set up -->
<div
v-if="isSettingUp"
@@ -41,8 +41,8 @@
<div
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between md:gap-2"
>
<!-- OWNER Unsubscribed State -->
<template v-if="showSubscribePrompt">
<!-- OWNER Unsubscribed TEAM workspace -->
<template v-if="showTeamSubscribePrompt">
<div class="flex flex-col gap-2">
<div class="text-sm font-bold text-text-primary">
{{ $t('subscription.workspaceNotSubscribed') }}
@@ -73,12 +73,55 @@
</div>
</template>
<!-- OWNER personal workspace without subscription (Free plan) -->
<template v-else-if="isPersonalFree">
<div class="flex flex-col gap-2">
<span class="text-base font-bold text-text-primary">
{{ $t('subscription.tiers.free.name') }}
</span>
<div class="flex items-baseline gap-1 font-inter">
<span class="text-2xl font-semibold">
${{ freeTierPrice }}
</span>
<span class="text-base">
{{ $t('subscription.usdPerMonth') }}
</span>
</div>
</div>
<div class="flex flex-wrap gap-2 md:ml-auto">
<Button
variant="primary"
size="lg"
class="rounded-lg px-4 text-sm font-normal"
@click="handleSubscribeWorkspace"
>
{{ $t('subscription.subscribe') }}
</Button>
<DropdownMenu
v-if="planMenuItems.length > 0"
:entries="planMenuEntries"
>
<template #button>
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="secondary"
size="icon-lg"
class="rounded-lg bg-interface-menu-component-surface-selected text-text-primary"
:aria-label="$t('g.moreOptions')"
>
<i class="pi pi-ellipsis-h" />
</Button>
</template>
</DropdownMenu>
</div>
</template>
<!-- Normal Subscribed State (Owner with subscription, or member viewing subscribed workspace) -->
<template v-else>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<span class="text-sm font-bold text-text-primary">
{{ subscriptionTierName }}
<span class="text-base font-bold text-text-primary">
{{ planDisplayName }}
</span>
<StatusBadge
v-if="isCancelled"
@@ -86,37 +129,26 @@
severity="warn"
/>
</div>
<div class="flex items-baseline gap-1 font-inter font-semibold">
<span class="text-2xl">${{ tierPrice }}</span>
<span class="text-base">
{{
isInPersonalWorkspace
? $t('subscription.usdPerMonth')
: $t('subscription.usdPerMonthPerMember')
}}
</span>
<div class="flex items-baseline gap-1 font-inter">
<span class="text-2xl font-semibold"
>${{ displayPrice }}</span
>
<span class="text-base">{{ priceUnitLabel }}</span>
</div>
<div
v-if="isActiveSubscription"
:class="
cn(
'text-sm',
isCancelled
? 'text-warning-background'
: 'text-text-secondary'
)
"
class="text-sm text-text-secondary"
>
<template v-if="isCancelled">
{{
$t('subscription.expiresDate', {
$t('subscription.endsOnDate', {
date: formattedEndDate
})
}}
</template>
<template v-else>
{{
$t('subscription.renewsDate', {
$t('subscription.renewsOnDate', {
date: formattedRenewalDate
})
}}
@@ -125,159 +157,89 @@
</div>
<div
v-if="isActiveSubscription && permissions.canManageSubscription"
v-if="isActiveSubscription"
class="flex flex-wrap gap-2 md:ml-auto"
>
<!-- Cancelled state: reactivation is original-owner-only. -->
<template v-if="isCancelled">
<Button
v-if="permissions.canManageSubscriptionLifecycle"
size="lg"
variant="primary"
class="rounded-lg px-4 text-sm font-normal"
:loading="isResubscribing"
@click="handleResubscribe"
>
{{ $t('subscription.resubscribe') }}
</Button>
</template>
<!-- Active state: show Manage Payment, Upgrade, and menu -->
<template v-else>
<Button
v-if="!isFreeTierPlan"
size="lg"
variant="secondary"
class="rounded-lg bg-interface-menu-component-surface-selected px-4 text-sm font-normal text-text-primary"
@click="manageSubscription"
>
{{ $t('subscription.managePayment') }}
</Button>
<Button
size="lg"
variant="primary"
class="rounded-lg px-4 text-sm font-normal text-text-primary"
@click="handleUpgrade"
>
{{ $t('subscription.upgradePlan') }}
</Button>
<Button
v-if="!isFreeTierPlan && planMenuItems.length > 0"
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="secondary"
size="lg"
:aria-label="$t('g.moreOptions')"
@click="planMenu?.toggle($event)"
>
<i class="pi pi-ellipsis-h" />
</Button>
<Menu ref="planMenu" :model="planMenuItems" :popup="true" />
</template>
<Button
v-if="!isFreeTierPlan && permissions.canManageSubscription"
size="lg"
variant="secondary"
class="rounded-lg bg-interface-menu-component-surface-selected px-4 text-sm font-normal text-text-primary"
@click="manageSubscription"
>
{{ $t('subscription.manageBilling') }}
</Button>
<Button
v-if="
isCancelled && permissions.canManageSubscriptionLifecycle
"
size="lg"
variant="primary"
class="rounded-lg px-4 text-sm font-normal"
:loading="isResubscribing"
@click="handleResubscribe"
>
{{ $t('subscription.reactivatePlan') }}
</Button>
<Button
v-else-if="!isCancelled && permissions.canManageSubscription"
size="lg"
variant="secondary"
class="rounded-lg bg-interface-menu-component-surface-selected px-4 text-sm font-normal text-text-primary"
@click="handleUpgrade"
>
{{
isInPersonalWorkspace
? $t('subscription.upgradePlan')
: $t('subscription.changePlan')
}}
</Button>
<DropdownMenu
v-if="planMenuItems.length > 0"
:entries="planMenuEntries"
>
<template #button>
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="secondary"
size="icon-lg"
class="rounded-lg bg-interface-menu-component-surface-selected text-text-primary"
:aria-label="$t('g.moreOptions')"
>
<i class="pi pi-ellipsis-h" />
</Button>
</template>
</DropdownMenu>
</div>
</template>
</div>
</div>
<div class="flex flex-col gap-6 pt-6 lg:flex-row lg:items-stretch">
<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 class="w-full lg:max-w-md">
<CreditsTile :zero-state="showZeroState" />
</div>
<div v-if="isActiveSubscription" class="flex flex-col gap-2">
<div class="text-sm text-text-primary">
<div
v-if="isActiveSubscription || isPersonalFree"
class="flex flex-col gap-2"
>
<i18n-t
v-if="isTeamActive"
keypath="subscription.teamPlanIncludes"
tag="div"
class="text-sm text-muted"
>
<template #plan>
<span class="text-text-primary">
{{ $t('subscription.tiers.pro.name') }}
</span>
</template>
</i18n-t>
<div v-else-if="isPersonalFree" class="text-sm text-muted">
{{ $t('subscription.whatsIncluded') }}
</div>
<div v-else class="text-sm text-text-primary">
{{ $t('subscription.yourPlanIncludes') }}
</div>
@@ -291,10 +253,6 @@
v-if="benefit.type === 'feature'"
class="pi pi-check text-xs text-text-primary"
/>
<i
v-else-if="benefit.type === 'icon' && benefit.icon"
:class="[benefit.icon, 'text-xs text-text-primary']"
/>
<span
v-else-if="benefit.type === 'metric' && benefit.value"
class="text-sm font-normal whitespace-nowrap text-text-primary"
@@ -310,73 +268,42 @@
</div>
</div>
<!-- Members invoice card -->
<div
v-if="
isActiveSubscription &&
!isInPersonalWorkspace &&
permissions.canManageSubscription
"
class="mt-6 flex items-center justify-between gap-1 rounded-2xl border border-interface-stroke p-6 text-sm"
>
<div class="flex flex-col gap-2">
<h4 class="m-0 text-sm text-text-primary">
{{ $t('subscription.nextMonthInvoice') }}
</h4>
<span
class="cursor-pointer text-muted-foreground underline"
@click="manageSubscription"
>
{{ $t('subscription.invoiceHistory') }}
</span>
</div>
<div class="flex flex-col items-end gap-2">
<h4 class="m-0 font-bold">${{ nextMonthInvoice }}</h4>
<h5 class="m-0 text-muted-foreground">
{{ $t('subscription.memberCount', memberCount) }}
</h5>
</div>
</div>
<!-- View More Details - Outside main content -->
<div
v-if="permissions.canManageSubscription"
class="flex items-center gap-2 py-6"
>
<i class="pi pi-external-link text-muted"></i>
<a
href="https://www.comfy.org/cloud/pricing"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-muted underline hover:opacity-80"
<div v-if="permissions.canManageSubscription" class="py-6">
<Button
variant="muted-textonly"
class="text-sm text-muted"
@click="handleViewMoreDetails"
>
{{ $t('subscription.viewMoreDetailsPlans') }}
</a>
<i class="pi pi-external-link text-muted" />
</Button>
</div>
<SubscriptionFooterLinks class="mt-auto pt-6" />
</template>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import Menu from 'primevue/menu'
import Skeleton from 'primevue/skeleton'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { MenuItem } from 'primevue/menuitem'
import { useToast } from 'primevue/usetoast'
import DropdownMenu from '@/components/common/DropdownMenu.vue'
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 SubscriptionFooterLinks from '@/platform/cloud/subscription/components/SubscriptionFooterLinks.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'
@@ -384,13 +311,12 @@ import type { TierBenefit } from '@/platform/cloud/subscription/utils/tierBenefi
import { getCommonTierBenefits } from '@/platform/cloud/subscription/utils/tierBenefits'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@comfyorg/tailwind-utils'
const workspaceStore = useTeamWorkspaceStore()
const { isWorkspaceSubscribed, isInPersonalWorkspace, members } =
const { isWorkspaceSubscribed, isInPersonalWorkspace } =
storeToRefs(workspaceStore)
const { permissions } = useWorkspaceUI()
const { t, n } = useI18n()
const { permissions, uiConfig } = useWorkspaceUI()
const { t, n, locale } = useI18n()
const toast = useToast()
const billingOperationStore = useBillingOperationStore()
@@ -400,15 +326,19 @@ const {
isActiveSubscription,
isFreeTier: isFreeTierPlan,
subscription,
teamCreditStops,
currentTeamCreditStop,
showSubscriptionDialog,
manageSubscription,
fetchStatus,
fetchBalance,
getMaxSeats,
resubscribe
} = useBillingContext()
const { showCancelSubscriptionDialog } = useDialogService()
const {
showCancelSubscriptionDialog,
showLeaveWorkspaceDialog,
showEditWorkspaceDialog,
showDeleteWorkspaceDialog
} = useDialogService()
const { showPricingTable } = useSubscriptionDialog()
const isResubscribing = ref(false)
@@ -451,6 +381,19 @@ const showSubscribePrompt = computed(() => {
return !isWorkspaceSubscribed.value
})
const showTeamSubscribePrompt = computed(
() => showSubscribePrompt.value && !isInPersonalWorkspace.value
)
// Personal workspace without subscription renders the Free plan header
const isPersonalFree = computed(
() => showSubscribePrompt.value && isInPersonalWorkspace.value
)
const isTeamActive = computed(
() => !isInPersonalWorkspace.value && isActiveSubscription.value
)
// MEMBER view without subscription - members can't manage subscription
const isMemberView = computed(
() =>
@@ -461,7 +404,7 @@ const isMemberView = computed(
// Show zero state for credits (no real billing data yet)
const showZeroState = computed(
() => showSubscribePrompt.value || isMemberView.value
() => showTeamSubscribePrompt.value || isMemberView.value
)
// Subscribe workspace - opens the subscription dialog (personal or workspace variant)
@@ -474,33 +417,31 @@ function handleUpgrade() {
else showSubscriptionDialog()
}
function handleUpgradeToAddCredits() {
showPricingTable()
function handleViewMoreDetails() {
window.open('https://www.comfy.org/cloud/pricing', '_blank')
}
const subscriptionTier = computed(() => subscription.value?.tier ?? null)
const isYearlySubscription = computed(
() => subscription.value?.duration === 'ANNUAL'
)
const formattedRenewalDate = computed(() => {
if (!subscription.value?.renewalDate) return ''
const renewalDate = new Date(subscription.value.renewalDate)
return renewalDate.toLocaleDateString('en-US', {
function formatSubtitleDate(isoDate: string | null | undefined) {
if (!isoDate) return ''
return new Date(isoDate).toLocaleDateString(locale.value, {
month: 'short',
day: 'numeric',
year: 'numeric'
})
})
}
const formattedEndDate = computed(() => {
if (!subscription.value?.endDate) return ''
const endDate = new Date(subscription.value.endDate)
return endDate.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
})
const formattedRenewalDate = computed(() =>
formatSubtitleDate(subscription.value?.renewalDate)
)
const formattedEndDate = computed(() =>
formatSubtitleDate(subscription.value?.endDate)
)
const subscriptionTierName = computed(() => {
const tier = subscriptionTier.value
@@ -512,24 +453,90 @@ const subscriptionTierName = computed(() => {
: baseName
})
const planMenu = ref<InstanceType<typeof Menu> | null>(null)
const planDisplayName = computed(() =>
isInPersonalWorkspace.value
? subscriptionTierName.value
: t('subscription.teamPlanName')
)
// Cancel is original-owner-only (creator); a promoted owner gets no menu items
// and the "more options" button is hidden (see template).
const planMenuItems = computed(() =>
permissions.value.canManageSubscriptionLifecycle
? [
{
label: t('subscription.cancelSubscription'),
icon: 'pi pi-times',
command: () => {
showCancelSubscriptionDialog(
subscription.value?.endDate ?? undefined
)
}
}
]
: []
// A workspace (team or personal) can't be deleted while its subscription is
// active and not yet cancelled — the owner must cancel first.
const isDeleteDisabled = computed(
() =>
isActiveSubscription.value && !(subscription.value?.isCancelled ?? false)
)
const deleteTooltip = computed(() =>
isDeleteDisabled.value
? t('workspacePanel.menu.deleteWorkspaceDisabledTooltip')
: undefined
)
const planMenuItems = computed<MenuItem[]>(() => {
const config = uiConfig.value
const items: MenuItem[] = []
if (config.showEditWorkspaceMenuItem) {
items.push({
label: t('workspacePanel.menu.editWorkspace'),
command: () => void showEditWorkspaceDialog()
})
}
// Cancel plan: the original owner of an active paid plan (team or personal).
if (
permissions.value.canManageSubscriptionLifecycle &&
isActiveSubscription.value &&
!isCancelled.value &&
!isFreeTierPlan.value
) {
items.push({
label: t('subscription.cancelPlan'),
command: () =>
showCancelSubscriptionDialog(subscription.value?.endDate ?? undefined)
})
}
// Delete: a team's original owner, or a personal owner on a paid plan.
if (
permissions.value.canManageSubscriptionLifecycle &&
(!isInPersonalWorkspace.value || isActiveSubscription.value)
) {
items.push({
label: t('workspacePanel.menu.deleteWorkspace'),
// Disabled Delete keeps the danger color and stays hoverable so its
// "cancel first" tooltip shows (the shared item style would otherwise mute
// it and block pointer events).
class: isDeleteDisabled.value
? 'data-disabled:cursor-not-allowed data-disabled:text-coral-700 data-disabled:pointer-events-auto'
: 'text-coral-700',
disabled: isDeleteDisabled.value,
tooltip: deleteTooltip.value,
command: isDeleteDisabled.value
? undefined
: () => void showDeleteWorkspaceDialog()
})
}
// Members and promoted (non-original) owners can leave; the creator cannot.
if (
!isInPersonalWorkspace.value &&
!permissions.value.canManageSubscriptionLifecycle
) {
items.push({
label: t('workspacePanel.menu.leaveWorkspace'),
command: () => void showLeaveWorkspaceDialog()
})
}
return items
})
// Figma 3343-25140 renders a divider between every menu option.
const planMenuEntries = computed<MenuItem[]>(() =>
planMenuItems.value.flatMap((item, index) =>
index === 0 ? [item] : [{ separator: true }, item]
)
)
const tierKey = computed(() => {
@@ -541,101 +548,61 @@ const tierPrice = computed(() =>
getTierPrice(tierKey.value, isYearlySubscription.value)
)
const memberCount = computed(() => members.value.length)
const nextMonthInvoice = computed(() => memberCount.value * tierPrice.value)
const freeTierPrice = getTierPrice('free')
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 billingCycleKey = computed(() =>
subscription.value?.duration === 'ANNUAL' ? 'yearly' : 'monthly'
)
const planTotalCredits = computed(() => {
const credits = getTierCredits(tierKey.value)
if (credits === null) return '—'
const total = isYearlySubscription.value ? credits * 12 : credits
return n(total)
const currentStop = computed(() => {
const id = currentTeamCreditStop.value?.id
const stops = teamCreditStops.value?.stops
if (!id || !stops) return null
return stops.find((stop) => stop.id === id) ?? null
})
const includedCreditsDisplay = computed(
() => `${monthlyBonusCredits.value} / ${planTotalCredits.value}`
// Team price is the subscribed credit stop's per-month price (both monthly and
// yearly stops are per-month); seats no longer drive team pricing. Falls back to
// the per-member tier price until stops resolve.
const workspacePlanCost = computed(() => {
const stop = currentStop.value
if (!stop) return null
return (stop[billingCycleKey.value].price_cents / 100).toFixed(0)
})
const displayPrice = computed(() => workspacePlanCost.value ?? tierPrice.value)
const priceUnitLabel = computed(() =>
workspacePlanCost.value !== null || isInPersonalWorkspace.value
? t('subscription.usdPerMonth')
: t('subscription.usdPerMonthPerMember')
)
const TEAM_PERK_KEYS = [
'inviteMembers',
'concurrentRuns',
'sharedCreditPool',
'rolePermissions'
] as const
const tierBenefits = computed((): TierBenefit[] => {
const key = tierKey.value
const benefits: TierBenefit[] = []
if (!isInPersonalWorkspace.value) {
benefits.push({
key: 'members',
type: 'icon',
label: t('subscription.membersLabel', { count: getMaxSeats(key) }),
icon: 'pi pi-user'
})
if (isTeamActive.value) {
return TEAM_PERK_KEYS.map((key) => ({
key,
type: 'feature',
label: t(`subscription.teamPerks.${key}`)
}))
}
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
if (isPersonalFree.value) {
return [
{
key: 'maxRuntime',
type: 'feature',
label: t('subscription.freePerks.maxRuntime', {
duration: t('subscription.maxDuration.free')
})
}
]
}
// 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)
return getCommonTierBenefits(tierKey.value, t, n)
})
</script>

View File

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

View File

@@ -44,9 +44,11 @@
</p>
</div>
<!-- Pricing Table Step (unified: personal/team plan toggle) -->
<!-- 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. -->
<UnifiedPricingTable
v-if="checkoutStep === 'pricing'"
v-show="checkoutStep === 'pricing'"
class="xl:flex-1"
:initial-plan-mode="initialPlanMode"
:is-loading="isLoadingPreview || isResubscribing"
@@ -56,48 +58,48 @@
@subscribe-team="handleSubscribeTeamClick"
/>
<!-- 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"
/>
<template v-if="checkoutStep === 'preview'">
<!-- New Subscription -->
<SubscriptionAddPaymentPreviewWorkspace
v-if="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"
/>
<!-- 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"
/>
<!-- Plan Transition -->
<SubscriptionTransitionPreviewWorkspace
v-else-if="
previewData && previewData.transition_type !== 'new_subscription'
"
:preview-data="previewData"
:is-loading="isSubscribing || isPolling"
@confirm="handleConfirmTransition"
@back="handleBackToPricing"
/>
<!-- 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"
/>
<!-- Team (display-only confirm; the slider stop and active billing cycle
drive the real subscribe). -->
<SubscriptionAddPaymentPreviewWorkspace
v-else-if="selectedTeamStop"
:team-plan="selectedTeamStop"
:billing-cycle="selectedBillingCycle"
:is-loading="isSubscribing || isPolling"
@add-credit-card="handleTeamSubscribe"
@back="handleBackToPricing"
/>
</template>
<!-- Success Step - "You're all set" -->
<SubscriptionSuccessWorkspace
v-else-if="checkoutStep === 'success' && selectedTierKey"
v-if="checkoutStep === 'success' && (selectedTierKey || isTeamCheckout)"
:tier-key="selectedTierKey"
:team-plan="selectedTeamStop"
:preview-data="previewData"
:is-team="isTeamCheckout"
@close="handleSuccessClose"
/>
</div>
@@ -105,8 +107,7 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { useToast } from 'primevue/usetoast'
import { useI18n } from 'vue-i18n'
import { useEventListener } from '@vueuse/core'
import Button from '@/components/ui/button/Button.vue'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
@@ -127,9 +128,6 @@ const emit = defineEmits<{
close: [subscribed: boolean]
}>()
const { t } = useI18n()
const toast = useToast()
const {
checkoutStep,
isLoadingPreview,
@@ -141,26 +139,31 @@ const {
selectedTeamStop,
selectedBillingCycle,
isPolling,
isTeamCheckout,
handleSubscribeClick,
handleSubscribeTeamClick,
handleBackToPricing,
handleSuccessClose,
handleAddCreditCard,
handleConfirmTransition,
handleTeamSubscribe,
handleResubscribe
} = useSubscriptionCheckout(emit)
// 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
})
}
// 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()
})
</script>

View File

@@ -2,7 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
@@ -17,6 +17,7 @@ const mockHandleResubscribe = vi.fn()
const mockHandleSuccessClose = vi.fn()
const mockCheckoutStep = ref<'pricing' | 'preview' | 'success'>('pricing')
const mockPreviewData = ref<{ transition_type: string } | null>(null)
const mockCanManageSubscription = ref(true)
vi.mock('@/platform/workspace/composables/useSubscriptionCheckout', () => ({
useSubscriptionCheckout: () => ({
@@ -38,6 +39,14 @@ vi.mock('@/platform/workspace/composables/useSubscriptionCheckout', () => ({
})
}))
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
permissions: computed(() => ({
canManageSubscription: mockCanManageSubscription.value
}))
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -46,7 +55,12 @@ const i18n = createI18n({
g: { back: 'Back', close: 'Close' },
subscription: {
plansForWorkspace: 'Plans for {workspace}',
teamWorkspace: 'Team'
teamWorkspace: 'Team',
inactive: {
memberTitle: 'This workspace is paused',
memberDescription:
'Contact your workspace owner to resubscribe and continue running workflows.'
}
},
credits: {
topUp: {
@@ -115,6 +129,7 @@ describe('SubscriptionRequiredDialogContentWorkspace', () => {
vi.clearAllMocks()
mockCheckoutStep.value = 'pricing'
mockPreviewData.value = null
mockCanManageSubscription.value = true
})
it('shows pricing table on pricing step', () => {
@@ -206,20 +221,67 @@ describe('SubscriptionRequiredDialogContentWorkspace', () => {
expect(mockHandleBackToPricing).toHaveBeenCalled()
})
it('shows the success screen on the success step', () => {
mockCheckoutStep.value = 'success'
renderComponent()
expect(screen.getByTestId('success')).toBeInTheDocument()
expect(screen.queryByTestId('pricing-table')).not.toBeInTheDocument()
describe('member (cannot manage subscription)', () => {
beforeEach(() => {
mockCanManageSubscription.value = false
})
it('shows the contact-owner resubscribe message instead of the pricing table', () => {
renderComponent()
expect(
screen.getByTestId('member-resubscribe-message')
).toBeInTheDocument()
expect(
screen.getByText(
'Contact your workspace owner to resubscribe and continue running workflows.'
)
).toBeInTheDocument()
})
it('does not expose any subscribe affordance to members', () => {
renderComponent()
expect(screen.queryByTestId('pricing-table')).not.toBeInTheDocument()
expect(screen.queryByTestId('subscribe-btn')).not.toBeInTheDocument()
expect(screen.queryByTestId('resubscribe-btn')).not.toBeInTheDocument()
})
it('falls back to the credits flow when the reason is out_of_credits', () => {
renderComponent({ reason: 'out_of_credits' })
expect(
screen.queryByTestId('member-resubscribe-message')
).not.toBeInTheDocument()
expect(screen.getByText('Insufficient Credits')).toBeInTheDocument()
})
})
it('wires the success close event to handleSuccessClose', async () => {
const user = userEvent.setup()
mockCheckoutStep.value = 'success'
renderComponent()
describe('owner (can manage subscription)', () => {
it('shows the pricing table and hides the member message', () => {
renderComponent()
await user.click(screen.getByTestId('success-close-btn'))
expect(screen.getByTestId('pricing-table')).toBeInTheDocument()
expect(
screen.queryByTestId('member-resubscribe-message')
).not.toBeInTheDocument()
})
expect(mockHandleSuccessClose).toHaveBeenCalled()
it('shows the success screen on the success step', () => {
mockCheckoutStep.value = 'success'
renderComponent()
expect(screen.getByTestId('success')).toBeInTheDocument()
expect(screen.queryByTestId('pricing-table')).not.toBeInTheDocument()
})
it('wires the success close event to handleSuccessClose', async () => {
const user = userEvent.setup()
mockCheckoutStep.value = 'success'
renderComponent()
await user.click(screen.getByTestId('success-close-btn'))
expect(mockHandleSuccessClose).toHaveBeenCalled()
})
})
})

View File

@@ -3,7 +3,7 @@
class="relative flex h-full flex-col gap-6 overflow-y-auto p-4 pt-8 md:px-16 md:py-8"
>
<Button
v-if="checkoutStep === 'preview'"
v-if="checkoutStep === 'preview' && !isMemberView"
size="icon"
variant="muted-textonly"
class="absolute top-2.5 left-2.5 shrink-0 rounded-full text-text-secondary hover:bg-white/10"
@@ -23,88 +23,112 @@
<i class="pi pi-times text-xl" />
</Button>
<div class="flex flex-col items-center gap-3">
<!-- Decorative initial for "Team" workspace icon; not user-facing text -->
<div
v-if="isMemberView"
class="flex flex-1 flex-col items-center justify-center gap-4 text-center"
data-testid="member-resubscribe-message"
>
<div
class="flex size-10 items-center justify-center rounded-xl bg-primary-background text-lg font-semibold text-white"
class="flex size-12 items-center justify-center rounded-full bg-warning-background/20 text-warning-background"
aria-hidden="true"
>
T
<i class="pi pi-lock text-2xl" />
</div>
<i18n-t
keypath="subscription.plansForWorkspace"
tag="h2"
class="m-0 font-inter text-2xl font-semibold text-base-foreground"
>
<template #workspace>
<span class="text-emerald-400">
{{ $t('subscription.teamWorkspace') }}
</span>
</template>
</i18n-t>
</div>
<div v-if="reason === 'out_of_credits'" class="text-center">
<h2 class="m-0 text-xl text-muted-foreground lg:text-2xl">
{{ $t('credits.topUp.insufficientTitle') }}
<h2 class="m-0 font-inter text-2xl font-semibold text-base-foreground">
{{ $t('subscription.inactive.memberTitle') }}
</h2>
<p class="m-0 mt-2 text-sm text-text-secondary">
{{ $t('credits.topUp.insufficientMessage') }}
<p class="m-0 max-w-md text-sm text-text-secondary">
{{ $t('subscription.inactive.memberDescription') }}
</p>
</div>
<!-- Pricing Table Step -->
<PricingTableWorkspace
v-if="checkoutStep === 'pricing'"
class="flex-1"
:is-loading="isLoadingPreview || isResubscribing"
:loading-tier="loadingTier"
@subscribe="handleSubscribeClick"
@resubscribe="handleResubscribe"
/>
<template v-else>
<div class="flex flex-col items-center gap-3">
<!-- Decorative initial for "Team" workspace icon; not user-facing text -->
<div
class="flex size-10 items-center justify-center rounded-xl bg-primary-background text-lg font-semibold text-white"
aria-hidden="true"
>
T
</div>
<i18n-t
keypath="subscription.plansForWorkspace"
tag="h2"
class="m-0 font-inter text-2xl font-semibold text-base-foreground"
>
<template #workspace>
<span class="text-emerald-400">
{{ $t('subscription.teamWorkspace') }}
</span>
</template>
</i18n-t>
</div>
<!-- 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"
/>
<div v-if="reason === 'out_of_credits'" class="text-center">
<h2 class="m-0 text-xl text-muted-foreground lg:text-2xl">
{{ $t('credits.topUp.insufficientTitle') }}
</h2>
<p class="m-0 mt-2 text-sm text-text-secondary">
{{ $t('credits.topUp.insufficientMessage') }}
</p>
</div>
<!-- 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"
/>
<!-- Pricing Table Step -->
<PricingTableWorkspace
v-if="checkoutStep === 'pricing'"
class="flex-1"
:is-loading="isLoadingPreview || isResubscribing"
:loading-tier="loadingTier"
@subscribe="handleSubscribeClick"
@resubscribe="handleResubscribe"
/>
<!-- Success Step - subscribe/change-plan confirmation -->
<SubscriptionSuccessWorkspace
v-else-if="checkoutStep === 'success' && selectedTierKey"
:tier-key="selectedTierKey"
:preview-data="previewData"
@close="handleSuccessClose"
/>
<!-- 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"
/>
<!-- 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"
/>
<!-- Success Step - subscribe/change-plan confirmation -->
<SubscriptionSuccessWorkspace
v-else-if="checkoutStep === 'success' && selectedTierKey"
:tier-key="selectedTierKey"
:preview-data="previewData"
@close="handleSuccessClose"
/>
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { useSubscriptionCheckout } from '@/platform/workspace/composables/useSubscriptionCheckout'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import PricingTableWorkspace from './PricingTableWorkspace.vue'
import SubscriptionAddPaymentPreviewWorkspace from './SubscriptionAddPaymentPreviewWorkspace.vue'
@@ -116,6 +140,12 @@ const { onClose, reason } = defineProps<{
reason?: SubscriptionDialogReason
}>()
const { permissions } = useWorkspaceUI()
const isMemberView = computed(
() => !permissions.value.canManageSubscription && reason !== 'out_of_credits'
)
const emit = defineEmits<{
close: [subscribed: boolean]
}>()

View File

@@ -1,8 +1,9 @@
import userEvent from '@testing-library/user-event'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import { cleanup, render, screen } from '@testing-library/vue'
import { afterEach, beforeEach, 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'
@@ -13,36 +14,43 @@ vi.mock('vue-i18n', () => ({
})
}))
function makePreviewData(priceCents: number): PreviewSubscribeResponse {
return {
allowed: true,
transition_type: 'new_subscription',
effective_at: '2026-07-10T00: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: 'standard-monthly',
tier: 'STANDARD',
duration: 'MONTHLY',
price_cents: priceCents,
credits_cents: 0,
seat_summary: {
seat_count: 1,
total_cost_cents: priceCents,
total_credits_cents: 0
}
}
// Provide just the seat cap 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
}))
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'],
template: '<div data-testid="invite-form">seats:{{ maxSeats }}</div>'
}
}))
const TEAM_STOP = {
id: 'team_700',
usd: 700,
credits: 147_700,
discountedUsd: 630
}
function renderCard() {
function renderCard(props: Record<string, unknown> = {}) {
return render(SubscriptionSuccessWorkspace, {
props: {
tierKey: 'standard',
previewData: makePreviewData(1600)
tierKey: 'creator',
previewData: {
new_plan: { price_cents: 1600 }
} as unknown as PreviewSubscribeResponse,
...props
},
global: {
mocks: { $t: (key: string) => key },
@@ -55,16 +63,72 @@ function renderCard() {
})
}
function renderTeamCard(props: Record<string, unknown> = {}) {
return renderCard({
tierKey: null,
teamPlan: TEAM_STOP,
isTeam: true,
...props
})
}
describe('SubscriptionSuccessWorkspace', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFlags.teamWorkspacesEnabled = true
})
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('emits close when the close button is clicked', async () => {
const { emitted } = renderCard()
const { emitted } = renderCard({ isTeam: false })
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()
})
})

View File

@@ -3,7 +3,7 @@
class="mx-auto flex h-full max-w-[400px] flex-col items-stretch justify-between text-sm"
>
<div class="flex flex-col items-center gap-4 pt-8">
<i class="pi pi-check-circle text-success-foreground text-5xl" />
<i class="pi pi-check-circle text-5xl text-success-background" />
<h2
class="m-0 text-center text-xl font-semibold text-base-foreground lg:text-2xl"
>
@@ -33,10 +33,50 @@
</div>
</div>
<!-- Team success "Invite your team" block renders here (FE-965 / DES-394). -->
<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>
<p
v-if="invitedEmails.length > 0"
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>
<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"
@@ -50,17 +90,29 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } 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 type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
import { MAX_WORKSPACE_MEMBERS } from '@/platform/workspace/stores/teamWorkspaceStore'
const { tierKey, previewData = null } = defineProps<{
tierKey: Exclude<TierKey, 'free' | 'founder'>
import InviteMembersForm from './InviteMembersForm.vue'
const {
tierKey,
previewData = null,
teamPlan = null,
isTeam = false
} = defineProps<{
tierKey?: Exclude<TierKey, 'free' | 'founder'> | null
previewData?: PreviewSubscribeResponse | null
teamPlan?: TeamPlanSelection | null
isTeam?: boolean
}>()
defineEmits<{
@@ -68,14 +120,42 @@ defineEmits<{
}>()
const { t, n } = useI18n()
const { flags } = useFeatureFlags()
const tierName = computed(() => t(`subscription.tiers.${tierKey}.name`))
const displayPrice = computed(() =>
previewData?.new_plan
? (previewData.new_plan.price_cents / 100).toFixed(0)
: '0'
const tierName = computed(() =>
teamPlan
? t('subscription.teamPlan.name')
: t(`subscription.tiers.${tierKey}.name`)
)
const displayCredits = computed(() => n(getTierCredits(tierKey) ?? 0))
const displayPrice = computed(() => {
if (teamPlan) return String(teamPlan.discountedUsd)
return previewData?.new_plan
? (previewData.new_plan.price_cents / 100).toFixed(0)
: '0'
})
const displayCredits = computed(() =>
n(teamPlan ? teamPlan.credits : tierKey ? (getTierCredits(tierKey) ?? 0) : 0)
)
// A team plan caps members at a flat MAX_WORKSPACE_MEMBERS; the buyer already
// holds one seat post-upgrade, so invites fill the rest.
const invitableSeats = computed(() => MAX_WORKSPACE_MEMBERS - 1)
const showInviteBlock = computed(() => isTeam && flags.teamWorkspacesEnabled)
const invitedEmails = ref<string[]>([])
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()
}
function onInvited(emails: string[]) {
invitedEmails.value = emails
}
</script>

View File

@@ -0,0 +1,156 @@
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()
})
})

View File

@@ -1,144 +1,120 @@
<template>
<h2 class="m-0 mb-8 text-center text-xl text-muted-foreground lg:text-2xl">
{{ $t('subscription.preview.confirmPlanChange') }}
{{ confirmTitle }}
</h2>
<div
class="mx-auto flex h-full flex-col items-stretch justify-between text-sm"
class="mx-auto flex h-full max-w-[400px] flex-col items-stretch justify-between text-sm"
>
<div>
<!-- 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 }}
<!-- 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 }}
</span>
<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">
<span class="text-xl text-base-foreground">
{{ $t('subscription.usdPerMonth') }}
</span>
</div>
<template v-if="isImmediate">
<span class="text-muted-foreground">
{{
$t('subscription.preview.ends', { date: currentPeriodEndDate })
newIsYearly
? $t('subscription.billedYearly', {
total: annualTotalFormatted
})
: $t('subscription.billedMonthly')
}}
</span>
</div>
<!-- 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 class="text-muted-foreground">
{{ $t('subscription.preview.switchesToday') }}
</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>
</div>
<!-- Next Cycle Section -->
<div class="flex flex-col gap-3 pt-12 pb-6">
<span class="text-base-foreground">
</template>
<span v-else class="text-muted-foreground">
{{
$t('subscription.preview.everyMonthStarting', {
date: effectiveDate
})
$t('subscription.preview.startsOn', { date: effectiveDateLabel })
}}
</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>
<!-- Proration Section -->
<div
v-if="showProration"
class="flex flex-col gap-2 border-t border-border-subtle py-6"
>
<!-- 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="proratedRefundCents > 0"
class="flex items-center justify-between"
v-if="prorationCreditUsd > 0"
class="flex items-center justify-between text-muted-foreground"
>
<span class="text-muted-foreground">
<span>
{{
$t('subscription.preview.proratedRefund', {
plan: currentTierName
$t('subscription.preview.creditFromCurrent', {
plan: creditFromPlanLabel
})
}}
</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>
<span> {{ money(prorationCreditUsd) }}</span>
</div>
</div>
<!-- Total Due Section -->
<div class="flex flex-col gap-2 border-t border-border-subtle pt-6">
<!-- 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>
</div>
</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">
{{
$t('subscription.preview.billedEachMonth', {
amount: moneyShort(newMonthlyChargeUsd)
})
}}
</span>
</div>
<!-- Total Due -->
<div class="mt-10 flex flex-col gap-2 border-t border-border-subtle pt-8">
<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">
${{ totalDueToday }}
{{ money(totalDueTodayUsd) }}
</span>
</div>
<span class="text-sm text-muted-foreground">
{{
$t('subscription.preview.nextPaymentDue', {
date: nextPaymentDate
})
}}
</span>
<span class="text-sm text-muted-foreground">{{ totalNote }}</span>
</div>
</div>
@@ -147,13 +123,13 @@
<SubscriptionTermsNote />
<Button
variant="secondary"
variant="tertiary"
size="lg"
class="w-full rounded-lg"
:loading="isLoading"
@click="$emit('confirm')"
>
{{ $t('subscription.preview.switchToPlan', { plan: newTierName }) }}
{{ confirmCta }}
</Button>
<Button
@@ -177,12 +153,12 @@ import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspac
import SubscriptionTermsNote from './SubscriptionTermsNote.vue'
interface Props {
type PersonalTierKey = 'standard' | 'creator' | 'pro'
const { previewData, isLoading = false } = defineProps<{
previewData: PreviewSubscribeResponse
isLoading?: boolean
}
const { previewData, isLoading = false } = defineProps<Props>()
}>()
defineEmits<{
confirm: []
@@ -203,83 +179,112 @@ function formatDate(dateStr: string): string {
})
}
const currentTierName = computed(() =>
previewData.current_plan ? formatTierName(previewData.current_plan.tier) : ''
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(() => previewData.new_plan.duration === 'ANNUAL')
const currentIsYearly = computed(
() => previewData.current_plan?.duration === 'ANNUAL'
)
const isCadenceChange = computed(
() =>
!!previewData.current_plan &&
previewData.current_plan.duration !== previewData.new_plan.duration
)
const newTierName = computed(() => formatTierName(previewData.new_plan.tier))
const currentDisplayPrice = computed(() =>
previewData.current_plan
? (previewData.current_plan.price_cents / 100).toFixed(0)
: '0'
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 newDisplayPrice = computed(() =>
(previewData.new_plan.price_cents / 100).toFixed(0)
)
const newMonthlyCharge = computed(() =>
(previewData.new_plan.price_cents / 100).toFixed(2)
)
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 newMonthlyUsd = computed(() => {
const cents = previewData.new_plan.price_cents
return (newIsYearly.value ? cents / 12 : cents) / 100
})
const heroPrice = computed(() => newMonthlyUsd.value.toFixed(0))
const newDisplayCredits = computed(() => {
const tierKey = previewData.new_plan.tier.toLowerCase() as
| 'standard'
| 'creator'
| 'pro'
return n(getTierCredits(tierKey) ?? 0)
const annualTotalFormatted = computed(
() => `$${n(previewData.new_plan.price_cents / 100)}`
)
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 currentPeriodEndDate = computed(() =>
previewData.current_plan?.period_end
? formatDate(previewData.current_plan.period_end)
: ''
const subscriptionLineLabel = computed(() =>
newIsYearly.value
? t('subscription.preview.yearlySubscription')
: t('subscription.preview.newMonthlySubscription')
)
const creditFromPlanLabel = computed(() =>
isCadenceChange.value
? t('subscription.preview.currentMonthly')
: currentTierName.value
)
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 refillCredits = computed(() => {
const monthly = tierMonthlyCredits(previewData.new_plan.tier)
return n(newIsYearly.value ? monthly * 12 : monthly)
})
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 monthlyRefillCredits = computed(() =>
n(tierMonthlyCredits(previewData.new_plan.tier))
)
const refillLabel = computed(() =>
newIsYearly.value
? t('subscription.preview.creditsYoullGetToday')
: t('subscription.preview.eachMonthCreditsRefill')
)
const effectiveDateLabel = computed(() => formatDate(previewData.effective_at))
const nextPaymentDate = computed(() =>
previewData.new_plan.period_end
? formatDate(previewData.new_plan.period_end)
: formatDate(previewData.effective_at)
: 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.confirmChange')
)
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
})
)
</script>

View File

@@ -3,13 +3,20 @@ import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { CreateTopupResponse } from '@/platform/workspace/api/workspaceApi'
import TopUpCreditsDialogContentWorkspace from './TopUpCreditsDialogContentWorkspace.vue'
type TopupStatus = 'pending' | 'completed' | 'failed'
interface CreateTopupResponse {
billing_op_id: string
topup_id: string
status: TopupStatus
amount_cents: number
}
const mockFetchBalance = vi.fn()
const mockFetchStatus = vi.fn()
const mockTopup = vi.fn<(amountCents: number) => Promise<CreateTopupResponse>>()
const mockCreateTopup = vi.fn<() => Promise<CreateTopupResponse>>()
const mockStartOperation = vi.fn()
const mockShowSettings = vi.fn()
const mockToastAdd = vi.fn()
@@ -17,11 +24,16 @@ const mockToastAdd = vi.fn()
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
fetchBalance: mockFetchBalance,
fetchStatus: mockFetchStatus,
topup: (amountCents: number) => mockTopup(amountCents)
fetchStatus: mockFetchStatus
})
}))
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
workspaceApi: {
createTopup: () => mockCreateTopup()
}
}))
vi.mock('@/platform/workspace/stores/billingOperationStore', () => ({
useBillingOperationStore: () => ({
hasPendingOperations: false,
@@ -132,7 +144,7 @@ describe('TopUpCreditsDialogContentWorkspace', () => {
})
it('refreshes both balance and status after a completed top-up', async () => {
mockTopup.mockResolvedValue(topupResponse('completed'))
mockCreateTopup.mockResolvedValue(topupResponse('completed'))
renderDialog()
await clickAddCredits()
@@ -143,7 +155,7 @@ describe('TopUpCreditsDialogContentWorkspace', () => {
})
it('does not refresh balance or status for a pending top-up', async () => {
mockTopup.mockResolvedValue(topupResponse('pending'))
mockCreateTopup.mockResolvedValue(topupResponse('pending'))
renderDialog()
await clickAddCredits()
@@ -154,7 +166,7 @@ describe('TopUpCreditsDialogContentWorkspace', () => {
})
it('does not refresh balance or status for a failed top-up', async () => {
mockTopup.mockResolvedValue(topupResponse('failed'))
mockCreateTopup.mockResolvedValue(topupResponse('failed'))
renderDialog()
await clickAddCredits()

View File

@@ -176,7 +176,7 @@ const settingsDialog = useSettingsDialog()
const telemetry = useTelemetry()
const toast = useToast()
const { buildDocsUrl, docsPaths } = useExternalLink()
const { fetchBalance, fetchStatus, topup } = useBillingContext()
const { fetchBalance, topup, fetchStatus } = useBillingContext()
const billingOperationStore = useBillingOperationStore()
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
@@ -269,7 +269,7 @@ async function handleBuy() {
handleClose(false)
settingsDialog.show('workspace')
} else if (response.status === 'pending') {
billingOperationStore.startOperation(response.billing_op_id, 'topup')
void billingOperationStore.startOperation(response.billing_op_id, 'topup')
} else {
toast.add({
severity: 'error',

View File

@@ -55,12 +55,12 @@ function renderComponent(props: Record<string, unknown> = {}) {
components: { Button },
stubs: {
SelectButton: { template: '<div />' },
// Clicking emits a change to a different stop ($200) so tests can move
// the selection off the current stop.
// Clicking moves the v-model selection to a different stop ($200) so
// tests can move off the current stop.
CreditSlider: {
template:
'<button data-testid="team-slider" @click="$emit(\'change\', { index: 0, usd: 200, credits: 42200 })" />',
emits: ['change', 'update:modelValue']
'<button data-testid="team-slider" @click="$emit(\'update:modelValue\', 200)" />',
emits: ['update:modelValue']
}
}
}

View File

@@ -235,8 +235,9 @@
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 -->
@@ -425,10 +426,12 @@ import type {
TierKey,
TierPricing
} from '@/platform/cloud/subscription/constants/tierPricing'
import { useBillingPlans } from '@/platform/cloud/subscription/composables/useBillingPlans'
import {
DEFAULT_TEAM_PLAN_STOP_INDEX,
getDiscountedMonthlyUsd,
TEAM_PLAN_CREDIT_STOPS
TEAM_PLAN_CREDIT_STOPS,
mapApiTeamCreditStops
} from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
import type { TeamPlanSelection } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
@@ -453,14 +456,9 @@ const {
const emit = defineEmits<{
subscribe: [payload: { tierKey: CheckoutTierKey; billingCycle: BillingCycle }]
resubscribe: []
// 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]
subscribeTeam: [
payload: { stop: TeamPlanSelection; billingCycle: BillingCycle }
]
}>()
const { t, n } = useI18n()
@@ -612,17 +610,41 @@ const {
currentTeamCreditStop
} = useBillingContext()
const { teamCreditStops } = useBillingPlans()
const isCancelled = computed(() => subscription.value?.isCancelled ?? false)
const currentBillingCycle = ref<BillingCycle>('yearly')
// 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
// Team credit stops: backend-sourced when the API supplies them, otherwise the
// hardcoded DES-197 fallback so OSS / pre-deploy still renders.
const teamStops = computed(() =>
teamCreditStops.value
? mapApiTeamCreditStops(teamCreditStops.value.stops)
: TEAM_PLAN_CREDIT_STOPS
)
const teamCredits = ref<number>(
TEAM_PLAN_CREDIT_STOPS[DEFAULT_TEAM_PLAN_STOP_INDEX].credits
const teamDefaultStopIndex = computed(
() =>
teamCreditStops.value?.default_stop_index ?? DEFAULT_TEAM_PLAN_STOP_INDEX
)
const defaultTeamStop = computed(
() =>
teamStops.value[teamDefaultStopIndex.value] ??
teamStops.value[0] ??
TEAM_PLAN_CREDIT_STOPS[DEFAULT_TEAM_PLAN_STOP_INDEX]
)
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)
)
@@ -633,18 +655,16 @@ const isTeamSubscribed = computed(() => currentTeamCreditStop.value !== null)
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)
const i = teamStops.value.findIndex((stop) => stop.usd === usd)
return i === -1 ? null : i
})
// Start the slider on the current stop so an active subscriber sees their plan
// (disabled) and must move off it to change.
// (disabled) and must move off it to change. teamCredits follows reactively.
watch(
currentTeamCreditStop,
(stop) => {
if (!stop) return
teamUsd.value = stop.stop_usd
teamCredits.value = stop.credits_monthly
if (stop) teamUsd.value = stop.stop_usd
},
{ immediate: true }
)
@@ -655,7 +675,7 @@ watch(
const isTeamCurrentStopSelected = computed(
() =>
currentTeamStopIndex.value !== null &&
TEAM_PLAN_CREDIT_STOPS[currentTeamStopIndex.value]?.usd === teamUsd.value
teamStops.value[currentTeamStopIndex.value]?.usd === teamUsd.value
)
// Yearly and monthly at the same credit stop are distinct plans, so toggling
@@ -804,11 +824,6 @@ 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
@@ -817,13 +832,18 @@ function handleSubscribeTeam() {
emit('resubscribe')
return
}
const stop = selectedTeamStop.value
emit('subscribeTeam', {
usd: teamUsd.value,
credits: teamCredits.value,
discountedUsd: getDiscountedMonthlyUsd(
teamUsd.value,
currentBillingCycle.value
)
stop: {
id: stop.id,
usd: stop.usd,
credits: stop.credits,
discountedUsd: getDiscountedMonthlyUsd(
stop.usd,
currentBillingCycle.value
)
},
billingCycle: currentBillingCycle.value
})
}

View File

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

View File

@@ -0,0 +1,115 @@
<template>
<div
class="flex w-full max-w-[360px] 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 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="onCancel"
>
<i class="pi pi-times size-4" />
</button>
</div>
<!-- Body -->
<div class="p-4">
<template v-if="isPromotion">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.changeRoleDialog.promoteIntro') }}
</p>
<ul class="m-0 mt-1 list-disc ps-5 text-sm text-muted-foreground">
<li v-for="permission in promotePermissions" :key="permission">
{{ permission }}
</li>
</ul>
</template>
<p v-else class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.changeRoleDialog.demoteMessage') }}
</p>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 p-4">
<Button variant="muted-textonly" @click="onCancel">
{{ $t('g.cancel') }}
</Button>
<Button variant="secondary" size="lg" :loading @click="onConfirm">
{{
isPromotion
? $t('workspacePanel.changeRoleDialog.promoteConfirm')
: $t('workspacePanel.changeRoleDialog.demoteConfirm')
}}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { WorkspaceRole } from '@/platform/workspace/api/workspaceApi'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const { memberId, targetRole } = defineProps<{
memberId: string
memberName: string
targetRole: WorkspaceRole
}>()
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
const toast = useToast()
const { t } = useI18n()
const loading = ref(false)
const isPromotion = computed(() => targetRole === 'owner')
const promotePermissions = computed(() => [
t('workspacePanel.changeRoleDialog.promotePermissionCredits'),
t('workspacePanel.changeRoleDialog.promotePermissionManage'),
t('workspacePanel.changeRoleDialog.promotePermissionRoles')
])
function onCancel() {
dialogStore.closeDialog({ key: 'change-member-role' })
}
async function onConfirm() {
loading.value = true
try {
await workspaceStore.changeMemberRole(memberId, targetRole)
toast.add({
severity: 'success',
summary: t('workspacePanel.changeRoleDialog.success'),
life: 2000
})
dialogStore.closeDialog({ key: 'change-member-role' })
} catch {
toast.add({
severity: 'error',
summary: t('workspacePanel.changeRoleDialog.error')
})
} finally {
loading.value = false
}
}
</script>

View File

@@ -1,4 +1,6 @@
import { render, screen } from '@testing-library/vue'
/* eslint-disable testing-library/no-container */
/* eslint-disable testing-library/no-node-access */
import { render } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -42,10 +44,24 @@ const i18n = createI18n({
}
})
const ButtonStub = {
name: 'Button',
template:
'<button :disabled="disabled" :data-loading="loading" @click="$emit(\'click\')"><slot /></button>',
props: ['disabled', 'loading', 'variant', 'size']
}
const InputStub = {
name: 'Input',
template:
'<input :value="modelValue" :disabled="disabled" @input="$emit(\'update:modelValue\', $event.target.value)" />',
props: ['modelValue', 'disabled', 'placeholder', 'type']
}
function mountComponent(props: Record<string, unknown> = {}) {
const user = userEvent.setup()
const onConfirm = vi.fn().mockResolvedValue(undefined)
render(DowngradeRemoveMembersDialogContent, {
const { container } = render(DowngradeRemoveMembersDialogContent, {
props: {
planName: 'Founder',
planSlug: 'founder-monthly',
@@ -53,16 +69,33 @@ function mountComponent(props: Record<string, unknown> = {}) {
...props
},
global: {
plugins: [i18n]
plugins: [i18n],
stubs: {
Button: ButtonStub,
Input: InputStub
}
}
})
return { user, onConfirm }
return { container, user, onConfirm }
}
const getPhraseInput = () => screen.getByRole('textbox')
const getChangePlanButton = () =>
screen.getByRole('button', { name: 'Change plan' })
const getCancelButton = () => screen.getByRole('button', { name: 'Cancel' })
function getInput(container: Element): HTMLInputElement {
return container.querySelector('input') as HTMLInputElement
}
function getChangePlanButton(container: Element): HTMLButtonElement {
const buttons = Array.from(container.querySelectorAll('button'))
return buttons.find((b) =>
b.textContent?.includes('Change plan')
) as HTMLButtonElement
}
function getCancelButton(container: Element): HTMLButtonElement {
const buttons = Array.from(container.querySelectorAll('button'))
return buttons.find(
(b) => b.textContent?.trim() === 'Cancel'
) as HTMLButtonElement
}
describe('DowngradeRemoveMembersDialogContent', () => {
beforeEach(() => {
@@ -70,26 +103,27 @@ describe('DowngradeRemoveMembersDialogContent', () => {
})
it('disables Change plan until the exact phrase is typed', async () => {
const { user } = mountComponent()
expect(getChangePlanButton()).toBeDisabled()
const { container, user } = mountComponent()
const changePlan = getChangePlanButton(container)
expect(changePlan.disabled).toBe(true)
await user.type(getPhraseInput(), 'I understan')
expect(getChangePlanButton()).toBeDisabled()
await user.type(getInput(container), 'I understan')
expect(changePlan.disabled).toBe(true)
await user.type(getPhraseInput(), 'd')
expect(getChangePlanButton()).toBeEnabled()
await user.type(getInput(container), 'd')
expect(changePlan.disabled).toBe(false)
})
it('keeps Change plan disabled for a case-mismatched phrase', async () => {
const { user } = mountComponent()
await user.type(getPhraseInput(), 'i understand')
expect(getChangePlanButton()).toBeDisabled()
const { container, user } = mountComponent()
await user.type(getInput(container), 'i understand')
expect(getChangePlanButton(container).disabled).toBe(true)
})
it('invokes onConfirm with the plan slug and closes when confirmed', async () => {
const { user, onConfirm } = mountComponent()
await user.type(getPhraseInput(), 'I understand')
await user.click(getChangePlanButton())
const { container, user, onConfirm } = mountComponent()
await user.type(getInput(container), 'I understand')
await user.click(getChangePlanButton(container))
expect(onConfirm).toHaveBeenCalledWith('founder-monthly')
expect(mockCloseDialog).toHaveBeenCalledWith({
@@ -98,9 +132,9 @@ describe('DowngradeRemoveMembersDialogContent', () => {
})
it('closes without calling onConfirm when cancelled', async () => {
const { user, onConfirm } = mountComponent()
await user.type(getPhraseInput(), 'I understand')
await user.click(getCancelButton())
const { container, user, onConfirm } = mountComponent()
await user.type(getInput(container), 'I understand')
await user.click(getCancelButton(container))
expect(onConfirm).not.toHaveBeenCalled()
expect(mockCloseDialog).toHaveBeenCalledWith({
@@ -110,9 +144,9 @@ describe('DowngradeRemoveMembersDialogContent', () => {
it('shows an error toast and stays open when onConfirm rejects', async () => {
const onConfirm = vi.fn().mockRejectedValue(new Error('boom'))
const { user } = mountComponent({ onConfirm })
await user.type(getPhraseInput(), 'I understand')
await user.click(getChangePlanButton())
const { container, user } = mountComponent({ onConfirm })
await user.type(getInput(container), 'I understand')
await user.click(getChangePlanButton(container))
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })

View File

@@ -0,0 +1,167 @@
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('closes without inviting on Cancel', async () => {
const { user } = renderDialog()
await user.click(screen.getByRole('button', { name: 'g.cancel' }))
expect(mockCreateInvite).not.toHaveBeenCalled()
expect(mockCloseDialog).toHaveBeenCalledWith({ key: 'invite-member' })
})
})

View File

@@ -1,97 +1,49 @@
<template>
<div
class="flex w-full max-w-[512px] flex-col rounded-2xl border border-border-default bg-base-background"
class="flex w-lg max-w-full 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">
{{
step === 'email'
? $t('workspacePanel.inviteMemberDialog.title')
: $t('workspacePanel.inviteMemberDialog.linkStep.title')
}}
{{ $t('workspacePanel.inviteMemberDialog.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="onCancel"
@click="onClose"
>
<i class="pi pi-times size-4" />
</button>
</div>
<!-- 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>
<div v-if="step === 'form'" class="p-4">
<InviteMembersForm
source="settings_members"
:submit-label="$t('workspacePanel.invite')"
:cancel-label="$t('g.cancel')"
:placeholder="$t('workspacePanel.inviteMemberDialog.placeholder')"
@submitted="onSubmitted"
@cancel="onClose"
/>
</div>
<!-- Footer: Email 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"
:loading
:disabled="!isValidEmail"
@click="onCreateLink"
>
{{ $t('workspacePanel.inviteMemberDialog.createLink') }}
</Button>
</div>
</template>
<!-- Body: Link Step -->
<template v-else>
<div class="flex flex-col gap-4 p-4">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.inviteMemberDialog.linkStep.message') }}
<div class="p-4">
<p class="m-0 text-sm/5 text-muted-foreground">
{{
$t(
'workspacePanel.inviteMemberDialog.invitedMessage',
{ emails: invitedEmails.join(', ') },
invitedEmails.length
)
}}
</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>
<!-- 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') }}
<div class="flex items-center justify-end p-4">
<Button variant="secondary" size="lg" @click="onClose">
{{ $t('g.close') }}
</Button>
</div>
</template>
@@ -99,74 +51,23 @@
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@comfyorg/tailwind-utils'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import InviteMembersForm from '@/platform/workspace/components/InviteMembersForm.vue'
import { useDialogStore } from '@/stores/dialogStore'
const dialogStore = useDialogStore()
const toast = useToast()
const { t } = useI18n()
const workspaceStore = useTeamWorkspaceStore()
const loading = ref(false)
const email = ref('')
const step = ref<'email' | 'link'>('email')
const generatedLink = ref('')
const justCopied = ref(false)
const step = ref<'form' | 'invited'>('form')
const invitedEmails = ref<string[]>([])
const isValidEmail = computed(() => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email.value)
})
function onSubmitted(emails: string[]) {
invitedEmails.value = emails
step.value = 'invited'
}
function onCancel() {
function onClose() {
dialogStore.closeDialog({ key: 'invite-member' })
}
async function onCreateLink() {
if (!isValidEmail.value) return
loading.value = true
try {
generatedLink.value = await workspaceStore.createInviteLink(email.value)
step.value = 'link'
} catch (error) {
toast.add({
severity: 'error',
summary: t('workspacePanel.inviteMemberDialog.linkCopyFailed'),
detail: error instanceof Error ? error.message : undefined
})
} finally {
loading.value = false
}
}
async function onCopyLink() {
try {
await navigator.clipboard.writeText(generatedLink.value)
justCopied.value = true
setTimeout(() => {
justCopied.value = false
}, 759)
toast.add({
severity: 'success',
summary: t('workspacePanel.inviteMemberDialog.linkCopied'),
life: 2000
})
} catch {
toast.add({
severity: 'error',
summary: t('workspacePanel.inviteMemberDialog.linkCopyFailed')
})
}
}
function onSelectLink(event: Event) {
const input = event.target as HTMLInputElement
input.select()
}
</script>

View File

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

View File

@@ -15,78 +15,76 @@
:pt:icon:class="{ 'text-xl!': !isCurrentUser || !photoUrl }"
/>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<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 class="text-sm text-base-foreground">
{{ member.name }}
<span v-if="isCurrentUser" class="text-muted-foreground">
({{ $t('g.you') }})
</span>
<RoleBadge v-if="showRoleBadge" :role="member.role" />
</div>
</span>
<span class="text-sm text-muted-foreground">
{{ member.email }}
</span>
</div>
</div>
<span
v-if="showDateColumn && !isSingleSeatPlan"
v-if="showRoleColumn && !isSingleSeatPlan"
class="text-right text-sm text-muted-foreground"
>
{{ formatDate(member.joinDate) }}
{{
member.role === 'owner'
? $t('workspaceSwitcher.roleOwner')
: $t('workspaceSwitcher.roleMember')
}}
</span>
<div
v-if="canRemoveMembers && !isSingleSeatPlan"
v-if="canManageMembers && !isSingleSeatPlan"
class="flex items-center justify-end"
>
<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)"
<DropdownMenu
v-if="!isCurrentUser && !isOriginalOwner"
:entries="menuItems"
>
<i class="pi pi-ellipsis-h" />
</Button>
<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>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { MenuItem } from 'primevue/menuitem'
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 {
showRoleBadge = false,
showDateColumn = false,
canRemoveMembers = false,
showRoleColumn = false,
canManageMembers = false,
isSingleSeatPlan = false,
striped = false
isOriginalOwner = false,
striped = false,
menuItems = []
} = defineProps<{
member: WorkspaceMember
isCurrentUser: boolean
photoUrl?: string
gridCols: string
showRoleBadge?: boolean
showDateColumn?: boolean
canRemoveMembers?: boolean
showRoleColumn?: boolean
canManageMembers?: boolean
isSingleSeatPlan?: boolean
isOriginalOwner?: boolean
striped?: boolean
menuItems?: MenuItem[]
}>()
defineEmits<{
showMenu: [event: Event]
}>()
const { d } = useI18n()
function formatDate(date: Date): string {
return d(date, { dateStyle: 'medium' })
}
</script>

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
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 type { Slots } from 'vue'
import { computed, h, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import MembersPanelContent from './MembersPanelContent.vue'
@@ -11,21 +12,26 @@ import type {
WorkspaceMember
} from '../../../stores/teamWorkspaceStore'
const mockHandleCopyInviteLink = vi.fn()
const mockHandleResendInvite = vi.fn()
const mockHandleRevokeInvite = vi.fn()
const mockHandleCreateWorkspace = vi.fn()
const mockShowTeamPlans = vi.fn()
const mockSelectMember = vi.fn()
const mockMemberMenuItems = vi.fn(() => [])
const mockToggleSort = vi.fn()
const mockHandleInviteMember = vi.fn()
const {
mockMembers,
mockPendingInvites,
mockOriginalOwnerId,
mockFilteredMembers,
mockFilteredPendingInvites,
mockIsPersonalWorkspace,
mockIsSingleSeatPlan,
mockIsActiveSubscription,
mockIsOnTeamPlan,
mockHasMultipleMembers,
mockShowSearch,
mockShowViewTabs,
mockShowInviteButton,
mockIsInviteDisabled,
mockActiveView,
mockSearchQuery,
mockPermissions,
@@ -37,11 +43,16 @@ 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),
mockIsSingleSeatPlan: ref(false),
mockIsActiveSubscription: ref(true),
mockIsOnTeamPlan: ref(true),
mockActiveView: ref<'active' | 'pending'>('active'),
mockSearchQuery: ref(''),
mockPermissions: ref({
@@ -59,8 +70,7 @@ const {
showMembersList: true,
showPendingTab: true,
showSearch: true,
showDateColumn: true,
showRoleBadge: true,
showRoleColumn: true,
membersGridCols: 'grid-cols-[50%_40%_10%]',
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
headerGridCols: 'grid-cols-[50%_40%_10%]',
@@ -76,7 +86,14 @@ vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
searchQuery: mockSearchQuery,
activeView: mockActiveView,
maxSeats: computed(() => 20),
isSingleSeatPlan: mockIsSingleSeatPlan,
isOnTeamPlan: mockIsOnTeamPlan,
hasMultipleMembers: mockHasMultipleMembers,
showSearch: mockShowSearch,
showViewTabs: mockShowViewTabs,
showInviteButton: mockShowInviteButton,
isInviteDisabled: mockIsInviteDisabled,
inviteTooltip: computed(() => null),
handleInviteMember: mockHandleInviteMember,
personalWorkspaceMember: computed(() => ({
id: 'self',
name: 'Owner User',
@@ -87,26 +104,30 @@ vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
})),
filteredMembers: mockFilteredMembers,
filteredPendingInvites: mockFilteredPendingInvites,
memberMenuItems: computed(() => []),
memberMenuItems: mockMemberMenuItems,
isPersonalWorkspace: mockIsPersonalWorkspace,
members: mockMembers,
pendingInvites: mockPendingInvites,
permissions: mockPermissions,
uiConfig: mockUiConfig,
isActiveSubscription: mockIsActiveSubscription,
userPhotoUrl: ref(null),
isCurrentUser: (m: WorkspaceMember) =>
m.email.toLowerCase() === 'owner@example.com',
selectMember: mockSelectMember,
isOriginalOwner: (m: WorkspaceMember) => m.id === mockOriginalOwnerId.value,
toggleSort: mockToggleSort,
showTeamPlans: mockShowTeamPlans,
handleCopyInviteLink: mockHandleCopyInviteLink,
handleResendInvite: mockHandleResendInvite,
handleRevokeInvite: mockHandleRevokeInvite,
handleCreateWorkspace: mockHandleCreateWorkspace,
handleRemoveMember: vi.fn()
handleRemoveMember: vi.fn(),
handleChangeRole: 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',
@@ -138,7 +159,7 @@ function renderComponent() {
Button: ButtonStub,
SearchInput: SearchInputStub,
UserAvatar: true,
Menu: { template: '<div />', props: ['model', 'popup'] }
WorkspaceMenuButton: true
},
directives: { tooltip: () => {} }
}
@@ -163,7 +184,6 @@ 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
@@ -173,13 +193,19 @@ 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
mockIsSingleSeatPlan.value = false
mockIsActiveSubscription.value = true
mockIsOnTeamPlan.value = true
mockHasMultipleMembers.value = true
mockShowSearch.value = true
mockShowViewTabs.value = true
mockShowInviteButton.value = true
mockIsInviteDisabled.value = false
mockActiveView.value = 'active'
mockSearchQuery.value = ''
mockPermissions.value = {
@@ -197,8 +223,7 @@ describe('MembersPanelContent', () => {
showMembersList: true,
showPendingTab: true,
showSearch: true,
showDateColumn: true,
showRoleBadge: true,
showRoleColumn: true,
membersGridCols: 'grid-cols-[50%_40%_10%]',
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
headerGridCols: 'grid-cols-[50%_40%_10%]',
@@ -211,27 +236,31 @@ 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 personal workspace message and create workspace button', () => {
it('shows the upsell banner below the members card', () => {
renderComponent()
expect(
screen.getByText('workspacePanel.members.personalWorkspaceMessage')
).toBeTruthy()
expect(
screen.getByText('workspacePanel.members.createNewWorkspace')
screen.getByText('workspacePanel.members.upsellBanner')
).toBeTruthy()
})
it('calls handleCreateWorkspace when create button is clicked', async () => {
it('opens team plans on upgrade click', async () => {
renderComponent()
await userEvent.click(
screen.getByText('workspacePanel.members.createNewWorkspace')
screen.getByRole('button', {
name: /workspacePanel\.members\.upgradeToTeam/
})
)
expect(mockHandleCreateWorkspace).toHaveBeenCalled()
expect(mockShowTeamPlans).toHaveBeenCalled()
})
it('does not show search input', () => {
@@ -241,6 +270,19 @@ 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' }),
@@ -274,6 +316,23 @@ 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()
expect(
screen.queryAllByRole('button', { name: 'g.moreOptions' })
).toHaveLength(1)
})
})
describe('pending invites tab', () => {
@@ -285,47 +344,107 @@ describe('MembersPanelContent', () => {
).toBeTruthy()
})
it('triggers handleRevokeInvite on revoke click', async () => {
it('triggers handleRevokeInvite from the row menu cancel item', async () => {
mockActiveView.value = 'pending'
mockFilteredPendingInvites.value = [createInvite({ id: 'inv-42' })]
renderComponent()
const revokeBtn = screen.getByRole('button', {
name: 'workspacePanel.members.actions.revokeInvite'
})
await userEvent.click(revokeBtn)
expect(mockHandleRevokeInvite).toHaveBeenCalled()
await userEvent.click(
screen.getByRole('button', {
name: 'workspacePanel.members.actions.cancelInvite'
})
)
expect(mockHandleRevokeInvite).toHaveBeenCalledWith(
expect.objectContaining({ id: 'inv-42' })
)
})
it('triggers handleCopyInviteLink on copy click', async () => {
it('triggers handleResendInvite from the row menu resend item', async () => {
mockActiveView.value = 'pending'
mockFilteredPendingInvites.value = [createInvite({ id: 'inv-42' })]
renderComponent()
const copyBtn = screen.getByRole('button', {
name: 'workspacePanel.members.actions.copyLink'
})
await userEvent.click(copyBtn)
expect(mockHandleCopyInviteLink).toHaveBeenCalled()
await userEvent.click(
screen.getByRole('button', {
name: 'workspacePanel.members.actions.resendInvite'
})
)
expect(mockHandleResendInvite).toHaveBeenCalledWith(
expect.objectContaining({ id: 'inv-42' })
)
})
})
describe('single seat plan', () => {
describe('member role', () => {
beforeEach(() => {
mockIsSingleSeatPlan.value = true
mockPermissions.value = {
canViewOtherMembers: true,
canViewPendingInvites: false,
canInviteMembers: false,
canManageInvites: false,
canRemoveMembers: 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
})
it('shows upsell banner', () => {
renderComponent()
expect(
screen.getByText('workspacePanel.members.upsellBannerUpgrade')
screen.getByText('workspacePanel.members.upsellBanner')
).toBeTruthy()
})
it('opens team plans on view plans click', async () => {
it('hides the upsell banner when on a team plan', () => {
mockIsOnTeamPlan.value = true
renderComponent()
const viewPlansBtn = screen.getByRole('button', {
name: /workspacePanel\.members\.viewPlans/
expect(
screen.queryByText('workspacePanel.members.upsellBanner')
).toBeNull()
})
it('opens team plans on upgrade click', async () => {
renderComponent()
const upgradeBtn = screen.getByRole('button', {
name: /workspacePanel\.members\.upgradeToTeam/
})
await userEvent.click(viewPlansBtn)
await userEvent.click(upgradeBtn)
expect(mockShowTeamPlans).toHaveBeenCalled()
})
@@ -333,6 +452,36 @@ 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 the cloud enterprise page 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://comfy.org/cloud/enterprise',
'_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', () => {
@@ -348,4 +497,42 @@ 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()
})
})
})

View File

@@ -8,15 +8,17 @@
<div class="flex min-w-0 flex-1 items-baseline gap-2">
<span class="text-base font-semibold text-base-foreground">
<template v-if="activeView === 'active'">
{{
$t('workspacePanel.members.membersCount', {
count:
isSingleSeatPlan || isPersonalWorkspace
? 1
: members.length,
maxSeats: maxSeats
})
}}
<template v-if="isOnTeamPlan && !isPersonalWorkspace">
{{
$t('workspacePanel.members.membersCount', {
count: members.length,
maxSeats: maxSeats
})
}}
</template>
<template v-else>
{{ $t('workspacePanel.members.header') }}
</template>
</template>
<template v-else-if="permissions.canViewPendingInvites">
{{
@@ -28,16 +30,31 @@
</template>
</span>
</div>
<div
v-if="uiConfig.showSearch && !isSingleSeatPlan"
class="flex items-start gap-2"
>
<div class="flex items-center gap-2">
<SearchInput
v-if="showSearch"
v-model="searchQuery"
:placeholder="$t('g.search')"
:placeholder="$t('workspacePanel.members.searchPlaceholder')"
size="lg"
class="w-64"
/>
<Button
v-if="showInviteButton"
v-tooltip="
inviteTooltip
? { value: inviteTooltip, showDelay: 0 }
: { value: $t('workspacePanel.inviteMember'), showDelay: 300 }
"
variant="secondary"
size="lg"
:disabled="isInviteDisabled"
:aria-label="$t('workspacePanel.inviteMember')"
@click="handleInviteMember"
>
{{ $t('workspacePanel.invite') }}
<i class="pi pi-plus text-sm" />
</Button>
<WorkspaceMenuButton v-if="permissions.canAccessWorkspaceMenu" />
</div>
</div>
@@ -45,20 +62,18 @@
<div class="flex min-h-0 flex-1 flex-col">
<!-- Table Header with Tab Buttons and Column Headers -->
<div
v-if="uiConfig.showMembersList"
v-if="uiConfig.showMembersList && showViewTabs"
:class="
cn(
'grid w-full items-center py-2',
isSingleSeatPlan
? 'grid-cols-1 py-0'
: activeView === 'pending'
? uiConfig.pendingGridCols
: uiConfig.headerGridCols
activeView === 'pending'
? uiConfig.pendingGridCols
: uiConfig.headerGridCols
)
"
>
<!-- Tab buttons in first column -->
<div v-if="!isSingleSeatPlan" class="flex items-center gap-2">
<div class="flex items-center gap-2">
<Button
:variant="
activeView === 'active' ? 'secondary' : 'muted-textonly'
@@ -107,19 +122,17 @@
<div />
</template>
<template v-else>
<template v-if="!isSingleSeatPlan">
<Button
variant="muted-textonly"
size="sm"
class="justify-end"
@click="toggleSort('joinDate')"
>
{{ $t('workspacePanel.members.columns.joinDate') }}
<i class="icon-[lucide--chevrons-up-down] size-4" />
</Button>
<!-- Empty cell for action column header (OWNER only) -->
<div v-if="permissions.canRemoveMembers" />
</template>
<Button
variant="muted-textonly"
size="sm"
class="justify-end"
@click="toggleSort('role')"
>
{{ $t('workspacePanel.members.columns.role') }}
<i class="icon-[lucide--chevrons-up-down] size-4" />
</Button>
<!-- Empty cell for action column header (OWNER only) -->
<div v-if="permissions.canRemoveMembers" />
</template>
</div>
@@ -134,7 +147,6 @@
:is-current-user="true"
:photo-url="userPhotoUrl ?? undefined"
:grid-cols="uiConfig.membersGridCols"
:show-role-badge="uiConfig.showRoleBadge"
/>
</template>
@@ -151,70 +163,78 @@
: undefined
"
:grid-cols="uiConfig.membersGridCols"
:show-role-badge="uiConfig.showRoleBadge"
:show-date-column="uiConfig.showDateColumn"
:can-remove-members="permissions.canRemoveMembers"
:is-single-seat-plan="isSingleSeatPlan"
:show-role-column="
uiConfig.showRoleColumn && hasMultipleMembers
"
:can-manage-members="permissions.canRemoveMembers"
:is-single-seat-plan="!isOnTeamPlan"
:is-original-owner="isOriginalOwner(member)"
:striped="index % 2 === 1"
@show-menu="showMemberMenu($event, member)"
:menu-items="memberMenuItems(member)"
/>
<!-- Member actions menu (shared for all members) -->
<Menu ref="memberMenu" :model="memberMenuItems" :popup="true" />
</template>
</template>
<!-- Upsell Banner -->
<MemberUpsellBanner
v-if="isSingleSeatPlan"
:is-active-subscription="isActiveSubscription"
@show-plans="showTeamPlans()"
/>
<!-- Pending Invites -->
<PendingInvitesList
v-if="activeView === 'pending'"
:invites="filteredPendingInvites"
:grid-cols="uiConfig.pendingGridCols"
@copy-link="handleCopyInviteLink"
@resend="handleResendInvite"
@revoke="handleRevokeInvite"
/>
</div>
</div>
</div>
<!-- Personal Workspace Message -->
<div v-if="isPersonalWorkspace" class="flex items-center">
<!-- Upsell Banner -->
<MemberUpsellBanner
v-if="!isOnTeamPlan"
:reactivate="hasLapsedTeamPlan"
@show-plans="showTeamPlans()"
/>
<!-- Need More Members Footer -->
<div
v-if="isOnTeamPlan && !isPersonalWorkspace"
class="flex items-center pt-2"
>
<p class="text-sm text-muted-foreground">
{{ $t('workspacePanel.members.personalWorkspaceMessage') }}
{{ $t('workspacePanel.members.needMoreMembers') }}
</p>
<button
class="cursor-pointer border-none bg-transparent underline"
@click="handleCreateWorkspace"
<Button
variant="muted-textonly"
size="sm"
class="text-base-foreground"
@click="handleContactUs"
>
{{ $t('workspacePanel.members.createNewWorkspace') }}
</button>
{{ $t('workspacePanel.members.contactUs') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import Menu from 'primevue/menu'
import { ref } from 'vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Button from '@/components/ui/button/Button.vue'
import MemberListItem from '@/platform/workspace/components/dialogs/settings/MemberListItem.vue'
import MemberUpsellBanner from '@/platform/workspace/components/dialogs/settings/MemberUpsellBanner.vue'
import PendingInvitesList from '@/platform/workspace/components/dialogs/settings/PendingInvitesList.vue'
import WorkspaceMenuButton from '@/platform/workspace/components/dialogs/settings/WorkspaceMenuButton.vue'
import { useMembersPanel } from '@/platform/workspace/composables/useMembersPanel'
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@comfyorg/tailwind-utils'
const {
searchQuery,
activeView,
maxSeats,
isSingleSeatPlan,
isOnTeamPlan,
hasLapsedTeamPlan,
hasMultipleMembers,
showSearch,
showViewTabs,
showInviteButton,
isInviteDisabled,
inviteTooltip,
handleInviteMember,
personalWorkspaceMember,
filteredMembers,
filteredPendingInvites,
@@ -224,21 +244,20 @@ const {
pendingInvites,
permissions,
uiConfig,
isActiveSubscription,
userPhotoUrl,
isCurrentUser,
selectMember,
isOriginalOwner,
toggleSort,
showTeamPlans,
handleCopyInviteLink,
handleRevokeInvite,
handleCreateWorkspace
handleResendInvite,
handleRevokeInvite
} = useMembersPanel()
const memberMenu = ref<InstanceType<typeof Menu> | null>(null)
function showMemberMenu(event: Event, member: WorkspaceMember) {
selectMember(member)
memberMenu.value?.toggle(event)
function handleContactUs() {
window.open(
'https://comfy.org/cloud/enterprise',
'_blank',
'noopener,noreferrer'
)
}
</script>

View File

@@ -0,0 +1,85 @@
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 { h } from 'vue'
import { createI18n } from 'vue-i18n'
import PendingInvitesList from './PendingInvitesList.vue'
import type { PendingInvite } from '../../../stores/teamWorkspaceStore'
const mockMenuClose = vi.hoisted(() => vi.fn())
vi.mock('@/components/button/MoreButton.vue', () => ({
default: (_: unknown, { slots }: { slots: Slots }) =>
h('div', slots.default?.({ close: mockMenuClose }))
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
function createInvite(overrides: Partial<PendingInvite> = {}): PendingInvite {
return {
id: 'invite-1',
email: 'invitee@example.com',
inviteDate: new Date('2025-03-01'),
expiryDate: new Date('2025-04-01'),
...overrides
}
}
function renderComponent(invites: PendingInvite[]) {
return render(PendingInvitesList, {
props: {
invites,
gridCols: 'grid-cols-[50%_20%_20%_10%]'
},
global: { plugins: [i18n] }
})
}
describe('PendingInvitesList', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('shows the empty state without action buttons when there are no invites', () => {
renderComponent([])
expect(screen.getByText('workspacePanel.members.noInvites')).toBeTruthy()
expect(screen.queryAllByRole('button')).toHaveLength(0)
})
it('emits resend with the invite and closes the menu', async () => {
const invite = createInvite({ id: 'inv-7' })
const { emitted } = renderComponent([invite])
await userEvent.click(
screen.getByRole('button', {
name: 'workspacePanel.members.actions.resendInvite'
})
)
expect(emitted('resend')).toEqual([[invite]])
expect(mockMenuClose).toHaveBeenCalled()
})
it('emits revoke with the invite from the cancel item', async () => {
const invite = createInvite({ id: 'inv-8' })
const { emitted } = renderComponent([invite])
await userEvent.click(
screen.getByRole('button', {
name: 'workspacePanel.members.actions.cancelInvite'
})
)
expect(emitted('revoke')).toEqual([[invite]])
})
})

View File

@@ -34,31 +34,37 @@
<span class="text-sm text-muted-foreground">
{{ formatDate(invite.expiryDate) }}
</span>
<div class="flex items-center justify-end gap-2">
<Button
v-tooltip="{
value: $t('workspacePanel.members.actions.copyLink'),
showDelay: 300
}"
variant="secondary"
size="md"
:aria-label="$t('workspacePanel.members.actions.copyLink')"
@click="$emit('copyLink', invite)"
>
<i class="icon-[lucide--link] size-4" />
</Button>
<Button
v-tooltip="{
value: $t('workspacePanel.members.actions.revokeInvite'),
showDelay: 300
}"
variant="secondary"
size="md"
:aria-label="$t('workspacePanel.members.actions.revokeInvite')"
@click="$emit('revoke', invite)"
>
<i class="icon-[lucide--mail-x] size-4" />
</Button>
<div class="flex items-center justify-end">
<MoreButton v-slot="{ close }" :aria-label="$t('g.moreOptions')">
<Button
variant="textonly"
size="unset"
:class="menuItemClass"
@click="
() => {
close()
$emit('resend', invite)
}
"
>
<i class="icon-[lucide--mail-plus] size-4" />
<span>{{ $t('workspacePanel.members.actions.resendInvite') }}</span>
</Button>
<Button
variant="textonly"
size="unset"
:class="menuItemClass"
@click="
() => {
close()
$emit('revoke', invite)
}
"
>
<i class="icon-[lucide--mail-x] size-4" />
<span>{{ $t('workspacePanel.members.actions.cancelInvite') }}</span>
</Button>
</MoreButton>
</div>
</div>
<div
@@ -73,17 +79,20 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import MoreButton from '@/components/button/MoreButton.vue'
import Button from '@/components/ui/button/Button.vue'
import type { PendingInvite } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@comfyorg/tailwind-utils'
const menuItemClass = 'w-full justify-start rounded-sm px-3 py-2'
defineProps<{
invites: PendingInvite[]
gridCols: string
}>()
defineEmits<{
copyLink: [invite: PendingInvite]
resend: [invite: PendingInvite]
revoke: [invite: PendingInvite]
}>()

View File

@@ -0,0 +1,161 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
import WorkspaceMenuButton from './WorkspaceMenuButton.vue'
const CREATOR = {
id: 'creator',
name: 'Creator',
email: 'creator@test.com',
joinDate: new Date('2026-01-01T00:00:00Z'),
role: 'owner' as const,
isOriginalOwner: true
}
const LATER_OWNER = {
id: 'owner-2',
name: 'Owner Two',
email: 'owner2@test.com',
joinDate: new Date('2026-02-01T00:00:00Z'),
role: 'owner' as const,
isOriginalOwner: false
}
const LATER_MEMBER = {
id: 'member-2',
name: 'Member Two',
email: 'member2@test.com',
joinDate: new Date('2026-02-01T00:00:00Z'),
role: 'member' as const,
isOriginalOwner: false
}
const ownerConfig = {
showEditWorkspaceMenuItem: true,
workspaceMenuAction: 'delete' as const,
workspaceMenuDisabledTooltip:
'workspacePanel.menu.deleteWorkspaceDisabledTooltip'
}
const memberConfig = {
showEditWorkspaceMenuItem: false,
workspaceMenuAction: 'leave' as const,
workspaceMenuDisabledTooltip: null
}
const mockUiConfig = ref<Record<string, unknown>>(ownerConfig)
const mockMembers = ref<WorkspaceMember[]>([CREATOR, LATER_OWNER])
const mockUserEmail = ref<string | null>('owner2@test.com')
const mockIsWorkspaceSubscribed = ref(false)
const mockShowLeaveWorkspaceDialog = vi.fn()
const mockShowDeleteWorkspaceDialog = vi.fn()
const mockShowEditWorkspaceDialog = vi.fn()
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({ uiConfig: mockUiConfig })
}))
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
isWorkspaceSubscribed: mockIsWorkspaceSubscribed,
members: mockMembers
})
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({ userEmail: mockUserEmail })
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showLeaveWorkspaceDialog: mockShowLeaveWorkspaceDialog,
showDeleteWorkspaceDialog: mockShowDeleteWorkspaceDialog,
showEditWorkspaceDialog: mockShowEditWorkspaceDialog
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
const DropdownMenuStub = {
props: ['entries'],
template:
'<div data-testid="menu"><button v-for="entry in entries" :key="entry.label" type="button" :disabled="entry.disabled" @click="entry.command?.()">{{ entry.label }}</button></div>'
}
function renderComponent() {
return render(WorkspaceMenuButton, {
global: {
plugins: [i18n],
directives: { tooltip: {} },
stubs: { DropdownMenu: DropdownMenuStub, Button: true }
}
})
}
describe('WorkspaceMenuButton', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUiConfig.value = ownerConfig
mockMembers.value = [CREATOR, LATER_OWNER]
mockUserEmail.value = 'owner2@test.com'
mockIsWorkspaceSubscribed.value = false
})
it('lets a member leave and offers no destructive workspace actions', () => {
mockUiConfig.value = memberConfig
mockMembers.value = [CREATOR, LATER_MEMBER]
mockUserEmail.value = 'member2@test.com'
renderComponent()
const leave = screen.getByRole('button', { name: 'Leave Workspace' })
expect(leave).toBeEnabled()
expect(
screen.queryByRole('button', { name: 'Delete Workspace' })
).not.toBeInTheDocument()
})
it('lets a non-creator owner leave', () => {
renderComponent()
expect(
screen.getByRole('button', { name: 'Leave Workspace' })
).toBeEnabled()
expect(
screen.getByRole('button', { name: 'Delete Workspace' })
).toBeInTheDocument()
})
it('shows the creator a disabled Leave option', () => {
mockUserEmail.value = 'creator@test.com'
renderComponent()
expect(
screen.getByRole('button', { name: 'Leave Workspace' })
).toBeDisabled()
})
it('opens the leave dialog when a non-creator owner clicks Leave', async () => {
const user = userEvent.setup()
renderComponent()
await user.click(screen.getByRole('button', { name: 'Leave Workspace' }))
expect(mockShowLeaveWorkspaceDialog).toHaveBeenCalledOnce()
})
it('does not open the leave dialog for the creator', async () => {
const user = userEvent.setup()
mockUserEmail.value = 'creator@test.com'
renderComponent()
await user.click(screen.getByRole('button', { name: 'Leave Workspace' }))
expect(mockShowLeaveWorkspaceDialog).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,112 @@
<template>
<DropdownMenu :entries="menuItems">
<template #button>
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="muted-textonly"
size="icon-lg"
:aria-label="$t('g.moreOptions')"
>
<i class="pi pi-ellipsis-h" />
</Button>
</template>
</DropdownMenu>
</template>
<script setup lang="ts">
import type { MenuItem } from 'primevue/menuitem'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import DropdownMenu from '@/components/common/DropdownMenu.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogService } from '@/services/dialogService'
const { t } = useI18n()
const {
showLeaveWorkspaceDialog,
showDeleteWorkspaceDialog,
showEditWorkspaceDialog
} = useDialogService()
const { isWorkspaceSubscribed, members } = storeToRefs(useTeamWorkspaceStore())
const { uiConfig } = useWorkspaceUI()
const { userEmail } = useCurrentUser()
// The creator (earliest-joined member) can't leave their own workspace.
const isCurrentUserCreator = computed(() => {
const email = userEmail.value?.toLowerCase()
if (!email || members.value.length === 0) return false
const currentMember = members.value.find(
(member) => member.email.toLowerCase() === email
)
return (
!!currentMember &&
members.value.every((member) => currentMember.joinDate <= member.joinDate)
)
})
// Disable delete when the workspace has an active subscription (prevents
// accidental deletion); uses the workspace's own status, not the global one.
const isDeleteDisabled = computed(
() =>
uiConfig.value.workspaceMenuAction === 'delete' &&
isWorkspaceSubscribed.value
)
const deleteTooltip = computed(() => {
if (!isDeleteDisabled.value) return undefined
const tooltipKey = uiConfig.value.workspaceMenuDisabledTooltip
return tooltipKey ? t(tooltipKey) : undefined
})
const menuItems = computed<MenuItem[]>(() => {
const items: MenuItem[] = []
if (uiConfig.value.showEditWorkspaceMenuItem) {
items.push({
label: t('workspacePanel.menu.editWorkspace'),
icon: 'pi pi-pencil',
command: () => showEditWorkspaceDialog()
})
}
const action = uiConfig.value.workspaceMenuAction
if (action === 'delete') {
items.push({
label: t('workspacePanel.menu.deleteWorkspace'),
icon: 'pi pi-trash',
class: isDeleteDisabled.value ? 'text-danger/50' : 'text-danger',
disabled: isDeleteDisabled.value,
tooltip: deleteTooltip.value,
command: isDeleteDisabled.value
? undefined
: () => showDeleteWorkspaceDialog()
})
}
// Members and non-creator owners can leave; the creator sees it disabled.
if (action === 'leave' || action === 'delete') {
items.push(
isCurrentUserCreator.value
? {
label: t('workspacePanel.menu.leaveWorkspace'),
icon: 'pi pi-sign-out',
class: 'opacity-50',
disabled: true,
tooltip: t('workspacePanel.menu.creatorCannotLeave')
}
: {
label: t('workspacePanel.menu.leaveWorkspace'),
icon: 'pi pi-sign-out',
command: () => showLeaveWorkspaceDialog()
}
)
}
return items
})
</script>

View File

@@ -0,0 +1,129 @@
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
import WorkspacePanelContent from './WorkspacePanelContent.vue'
const mockFetchMembers = vi.fn()
const mockFetchPendingInvites = vi.fn()
const { mockMembers, mockWorkspaceType } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
const { ref } = require('vue') as typeof import('vue')
return {
mockMembers: ref<WorkspaceMember[]>([]),
mockWorkspaceType: ref<'personal' | 'team'>('team')
}
})
vi.mock('pinia', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as object),
storeToRefs: (store: Record<string, unknown>) => store
}
})
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
const { ref } = require('vue') as typeof import('vue')
return {
useTeamWorkspaceStore: () => ({
workspaceName: ref('Acme Team'),
members: mockMembers,
fetchMembers: mockFetchMembers,
fetchPendingInvites: mockFetchPendingInvites
})
}
})
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
const { ref } = require('vue') as typeof import('vue')
return {
useWorkspaceUI: () => ({
workspaceType: mockWorkspaceType,
workspaceRole: ref('owner')
})
}
})
vi.mock(
'@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue',
() => ({
default: { name: 'SubscriptionPanelContentWorkspace', template: '<div />' }
})
)
vi.mock(
'@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue',
() => ({
default: { name: 'MembersPanelContent', template: '<div />' }
})
)
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
function createMember(id: string): WorkspaceMember {
return {
id,
name: `Member ${id}`,
email: `member${id}@example.com`,
joinDate: new Date('2025-01-15'),
role: 'member',
isOriginalOwner: false
}
}
function renderComponent() {
return render(WorkspacePanelContent, {
global: {
plugins: [i18n],
stubs: { WorkspaceProfilePic: true }
}
})
}
describe('WorkspacePanelContent members tab label', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMembers.value = []
mockWorkspaceType.value = 'team'
})
it('shows the counted label for team workspaces with multiple members', () => {
mockMembers.value = [createMember('1'), createMember('2')]
renderComponent()
expect(screen.getByText(/workspacePanel\.tabs\.membersCount/)).toBeTruthy()
})
it('drops the count when the owner is the only member', () => {
mockMembers.value = [createMember('1')]
renderComponent()
expect(screen.getByText('workspacePanel.members.header')).toBeTruthy()
expect(screen.queryByText(/workspacePanel\.tabs\.membersCount/)).toBeNull()
})
it('shows the plain Members label for personal workspaces', () => {
mockWorkspaceType.value = 'personal'
mockMembers.value = [createMember('1'), createMember('2')]
renderComponent()
expect(screen.getByText('workspacePanel.members.header')).toBeTruthy()
expect(screen.queryByText(/workspacePanel\.tabs\.membersCount/)).toBeNull()
})
it('fetches members and pending invites on mount', () => {
renderComponent()
expect(mockFetchMembers).toHaveBeenCalled()
expect(mockFetchPendingInvites).toHaveBeenCalled()
})
})

View File

@@ -1,107 +1,73 @@
<template>
<div class="flex size-full flex-col">
<header class="mb-8 flex items-center gap-4">
<header class="mb-6 flex items-center gap-4">
<WorkspaceProfilePic
class="size-12 text-3xl!"
:workspace-name="workspaceName"
/>
<h1 class="text-3xl text-base-foreground">
<h1 class="text-3xl font-semibold text-base-foreground">
{{ workspaceName }}
</h1>
</header>
<TabsRoot v-model="activeTab">
<div class="flex w-full items-center justify-between">
<TabsList class="flex items-center gap-2 pb-1">
<TabsTrigger
value="plan"
:class="
cn(
tabTriggerBase,
activeTab === 'plan' ? tabTriggerActive : tabTriggerInactive
)
"
>
{{ $t('workspacePanel.tabs.planCredits') }}
</TabsTrigger>
<TabsTrigger
value="members"
:class="
cn(
tabTriggerBase,
activeTab === 'members' ? tabTriggerActive : tabTriggerInactive
)
"
>
{{
$t('workspacePanel.tabs.membersCount', {
count: members.length
})
}}
</TabsTrigger>
</TabsList>
<div class="flex items-center gap-1">
<Button
v-if="permissions.canInviteMembers"
v-tooltip="
inviteTooltip
? { value: inviteTooltip, showDelay: 0 }
: { value: $t('workspacePanel.inviteMember'), showDelay: 300 }
"
variant="secondary"
size="lg"
:disabled="!isSingleSeatPlan && isInviteLimitReached"
:class="
!isSingleSeatPlan &&
isInviteLimitReached &&
'cursor-not-allowed opacity-50'
"
:aria-label="$t('workspacePanel.inviteMember')"
@click="handleInviteMember"
>
<i class="pi pi-plus text-sm" />
</Button>
<template v-if="permissions.canAccessWorkspaceMenu">
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="muted-textonly"
size="icon"
:aria-label="$t('g.moreOptions')"
@click="menu?.toggle($event)"
<TabsList class="flex items-center gap-2 pb-1">
<TabsTrigger
value="plan"
:class="
cn(
tabTriggerBase,
activeTab === 'plan' ? tabTriggerActive : tabTriggerInactive
)
"
>
<span class="grid">
<span
:class="
cn(
'col-start-1 row-start-1',
activeTab === 'plan' && 'font-bold'
)
"
>
<i class="pi pi-ellipsis-h" />
</Button>
<Menu ref="menu" :model="menuItems" :popup="true">
<template #item="{ item }">
<button
v-tooltip="
item.disabled && deleteTooltip
? { value: deleteTooltip, showDelay: 0 }
: null
"
type="button"
:disabled="!!item.disabled"
:class="
cn(
'flex w-full cursor-pointer items-center gap-2 border-none bg-transparent px-3 py-2',
item.class,
item.disabled && 'pointer-events-auto cursor-not-allowed'
)
"
@click="
item.command?.({
originalEvent: $event,
item
})
"
>
<i :class="item.icon" />
<span>{{ item.label }}</span>
</button>
</template>
</Menu>
</template>
</div>
</div>
{{ planTabLabel }}
</span>
<span
class="invisible col-start-1 row-start-1 font-bold"
aria-hidden="true"
>
{{ planTabLabel }}
</span>
</span>
</TabsTrigger>
<TabsTrigger
value="members"
:class="
cn(
tabTriggerBase,
activeTab === 'members' ? tabTriggerActive : tabTriggerInactive
)
"
>
<span class="grid">
<span
:class="
cn(
'col-start-1 row-start-1',
activeTab === 'members' && 'font-bold'
)
"
>
{{ membersTabLabel }}
</span>
<span
class="invisible col-start-1 row-start-1 font-bold"
aria-hidden="true"
>
{{ membersTabLabel }}
</span>
</span>
</TabsTrigger>
</TabsList>
<TabsContent value="plan" class="mt-4">
<SubscriptionPanelContentWorkspace />
@@ -115,7 +81,6 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import Menu from 'primevue/menu'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -123,19 +88,15 @@ import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
import MembersPanelContent from '@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
import SubscriptionPanelContentWorkspace from '@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogService } from '@/services/dialogService'
import { cn } from '@comfyorg/tailwind-utils'
const tabTriggerBase =
'flex items-center justify-center shrink-0 px-2.5 py-2 text-sm rounded-lg cursor-pointer transition-all duration-200 outline-hidden border-none'
const tabTriggerActive =
'bg-interface-menu-component-surface-hovered text-text-primary font-bold'
'bg-interface-menu-component-surface-hovered text-text-primary'
const tabTriggerInactive =
'bg-transparent text-text-secondary hover:bg-button-hover-surface focus:bg-button-hover-surface'
@@ -143,108 +104,26 @@ const { defaultTab = 'plan' } = defineProps<{
defaultTab?: string
}>()
const { t } = useI18n()
const {
showLeaveWorkspaceDialog,
showDeleteWorkspaceDialog,
showInviteMemberDialog,
showInviteMemberUpsellDialog,
showEditWorkspaceDialog
} = useDialogService()
const { isActiveSubscription, subscription, getMaxSeats } = useBillingContext()
const isSingleSeatPlan = computed(() => {
if (!isActiveSubscription.value) return true
const tier = subscription.value?.tier
if (!tier) return true
const tierKey = TIER_TO_KEY[tier]
if (!tierKey) return true
return getMaxSeats(tierKey) <= 1
})
const workspaceStore = useTeamWorkspaceStore()
const { workspaceName, members, isInviteLimitReached, isWorkspaceSubscribed } =
storeToRefs(workspaceStore)
const { workspaceName, members } = storeToRefs(workspaceStore)
const { fetchMembers, fetchPendingInvites } = workspaceStore
const { workspaceRole, permissions, uiConfig } = useWorkspaceUI()
const { workspaceType, workspaceRole } = useWorkspaceUI()
const isPersonalWorkspace = computed(() => workspaceType.value === 'personal')
const activeTab = ref(defaultTab)
const menu = ref<InstanceType<typeof Menu> | null>(null)
function handleLeaveWorkspace() {
showLeaveWorkspaceDialog()
}
function handleDeleteWorkspace() {
showDeleteWorkspaceDialog()
}
function handleEditWorkspace() {
showEditWorkspaceDialog()
}
// Disable delete when workspace has an active subscription (to prevent accidental deletion)
// Use workspace's own subscription status, not the global isActiveSubscription
const isDeleteDisabled = computed(
() =>
uiConfig.value.workspaceMenuAction === 'delete' &&
isWorkspaceSubscribed.value
// Per design, the tab counts members only when there is more than the owner
const showMembersTabCount = computed(
() => !isPersonalWorkspace.value && members.value.length > 1
)
const deleteTooltip = computed(() => {
if (!isDeleteDisabled.value) return null
const tooltipKey = uiConfig.value.workspaceMenuDisabledTooltip
return tooltipKey ? t(tooltipKey) : null
})
const inviteTooltip = computed(() => {
if (isSingleSeatPlan.value) return null
if (!isInviteLimitReached.value) return null
return t('workspacePanel.inviteLimitReached')
})
function handleInviteMember() {
if (isSingleSeatPlan.value) {
showInviteMemberUpsellDialog()
return
}
if (isInviteLimitReached.value) return
showInviteMemberDialog()
}
const menuItems = computed(() => {
const items = []
// Add edit option for owners
if (uiConfig.value.showEditWorkspaceMenuItem) {
items.push({
label: t('workspacePanel.menu.editWorkspace'),
icon: 'pi pi-pencil',
command: handleEditWorkspace
})
}
const action = uiConfig.value.workspaceMenuAction
if (action === 'delete') {
items.push({
label: t('workspacePanel.menu.deleteWorkspace'),
icon: 'pi pi-trash',
class: isDeleteDisabled.value
? 'text-danger/50 cursor-not-allowed'
: 'text-danger',
disabled: isDeleteDisabled.value,
command: isDeleteDisabled.value ? undefined : handleDeleteWorkspace
})
} else if (action === 'leave') {
items.push({
label: t('workspacePanel.menu.leaveWorkspace'),
icon: 'pi pi-sign-out',
command: handleLeaveWorkspace
})
}
return items
})
const { t } = useI18n()
const planTabLabel = computed(() => t('workspacePanel.tabs.planCredits'))
const membersTabLabel = computed(() =>
showMembersTabCount.value
? t('workspacePanel.tabs.membersCount', { count: members.value.length })
: t('workspacePanel.members.header')
)
onMounted(() => {
fetchMembers()

View File

@@ -1,12 +1,15 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDowngradeToPersonal } from './useDowngradeToPersonal'
const mockMembers = ref<WorkspaceMember[]>([])
const mockUserEmail = ref<string | null>(null)
const mockMembers = vi.hoisted(() => ({
value: [] as WorkspaceMember[]
}))
const mockUserEmail = vi.hoisted(() => ({
value: null as string | null
}))
const mockRemoveMember = vi.hoisted(() => vi.fn())
const mockFetchMembers = vi.hoisted(() => vi.fn())
const mockSubscribe = vi.hoisted(() => vi.fn())
@@ -49,8 +52,7 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
}))
vi.mock('@/i18n', () => ({
t: (key: string, params?: Record<string, unknown>) =>
params ? `${key} ${JSON.stringify(params)}` : key
t: (key: string) => key
}))
vi.mock('@/config/comfyApi', () => ({
@@ -166,11 +168,10 @@ describe('useDowngradeToPersonal', () => {
expect(mockRemoveMember).toHaveBeenCalledWith('m1')
expect(mockRemoveMember).toHaveBeenCalledWith('m2')
expect(mockRemoveMember).not.toHaveBeenCalledWith('owner')
expect(mockSubscribe).toHaveBeenCalledWith(
'founder-monthly',
'https://platform.test/payment/success',
'https://platform.test/payment/failed'
)
expect(mockSubscribe).toHaveBeenCalledWith('founder-monthly', {
returnUrl: 'https://platform.test/payment/success',
cancelUrl: 'https://platform.test/payment/failed'
})
expect(mockStartOperation).not.toHaveBeenCalled()
})
@@ -295,39 +296,15 @@ describe('useDowngradeToPersonal', () => {
expect(mockStartOperation).toHaveBeenCalledWith('op-4', 'subscription')
})
it('reports the generic failure when subscribe fails and no members were removed', async () => {
mockMembers.value = teamWithOwnerAnd()
mockSubscribe.mockResolvedValue(undefined)
const { downgradeToPersonal } = useDowngradeToPersonal()
await expect(downgradeToPersonal('founder-monthly')).rejects.toThrow(
/^subscription\.downgrade\.failed$/
)
})
it('reports members were already removed when subscribe fails after removal', async () => {
it('throws when subscribe resolves without a response', async () => {
mockMembers.value = teamWithOwnerAnd('m1')
mockSubscribe.mockResolvedValue(undefined)
const { downgradeToPersonal } = useDowngradeToPersonal()
await expect(downgradeToPersonal('founder-monthly')).rejects.toThrow(
'subscription.downgrade.failedAfterMemberRemoval'
'subscription.downgrade.failed'
)
})
it('surfaces which member failed and skips the plan change when removal throws', async () => {
mockMembers.value = teamWithOwnerAnd('m1', 'm2')
mockRemoveMember.mockImplementation((id: string) =>
id === 'm2' ? Promise.reject(new Error('network')) : Promise.resolve()
)
const { downgradeToPersonal } = useDowngradeToPersonal()
await expect(downgradeToPersonal('founder-monthly')).rejects.toThrow(
'm2@example.com'
)
expect(mockRemoveMember).toHaveBeenCalledWith('m1')
expect(mockSubscribe).not.toHaveBeenCalled()
})
})
describe('refreshMembers', () => {
@@ -338,7 +315,6 @@ describe('useDowngradeToPersonal', () => {
return Promise.resolve(mockMembers.value)
})
const { refreshMembers, hasOtherMembers } = useDowngradeToPersonal()
expect(hasOtherMembers.value).toBe(false)
await refreshMembers()

View File

@@ -42,31 +42,16 @@ export function useDowngradeToPersonal() {
throw new Error(preview?.reason || t('subscription.downgrade.notAllowed'))
}
const membersToRemove = removableMembers.value
for (const member of membersToRemove) {
try {
await workspaceStore.removeMember(member.id)
} catch (error) {
throw new Error(
t('subscription.downgrade.memberRemovalFailed', {
email: member.email
}),
{ cause: error }
)
}
for (const member of removableMembers.value) {
await workspaceStore.removeMember(member.id)
}
const response = await subscribe(
planSlug,
`${getComfyPlatformBaseUrl()}/payment/success`,
`${getComfyPlatformBaseUrl()}/payment/failed`
)
const response = await subscribe(planSlug, {
returnUrl: `${getComfyPlatformBaseUrl()}/payment/success`,
cancelUrl: `${getComfyPlatformBaseUrl()}/payment/failed`
})
if (!response) {
throw new Error(
membersToRemove.length > 0
? t('subscription.downgrade.failedAfterMemberRemoval')
: t('subscription.downgrade.failed')
)
throw new Error(t('subscription.downgrade.failed'))
}
if (response.status === 'needs_payment_method') {

View File

@@ -30,7 +30,6 @@ 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
@@ -38,7 +37,7 @@ function createInvite(overrides: Partial<PendingInvite> = {}): PendingInvite {
}
describe('sortMembers', () => {
it('places owners before members', () => {
it('places owners before members when sorting descending', () => {
const owner = createMember({ id: 'o', role: 'owner', name: 'Owner' })
const member = createMember({ id: 'm', role: 'member', name: 'Member' })
const result = sortMembers([member, owner], null, 'desc')
@@ -46,6 +45,14 @@ describe('sortMembers', () => {
expect(result[1].id).toBe('m')
})
it('places members before owners when sorting ascending', () => {
const owner = createMember({ id: 'o', role: 'owner', name: 'Owner' })
const member = createMember({ id: 'm', role: 'member', name: 'Member' })
const result = sortMembers([member, owner], null, 'asc')
expect(result[0].id).toBe('m')
expect(result[1].id).toBe('o')
})
it('places current user after owners but before others', () => {
const owner = createMember({
id: 'o',
@@ -107,6 +114,43 @@ describe('sortMembers', () => {
sortMembers(members, null, 'desc')
expect(members).toEqual(original)
})
it('pins the original owner first regardless of sort direction', () => {
const creator = createMember({
id: 'creator',
role: 'owner',
email: 'creator@test.com',
joinDate: new Date('2025-01-01')
})
const promoted = createMember({
id: 'promoted',
role: 'owner',
email: 'me@test.com',
joinDate: new Date('2025-02-01')
})
const member = createMember({
id: 'm',
role: 'member',
email: 'other@test.com',
joinDate: new Date('2025-03-01')
})
const desc = sortMembers(
[member, promoted, creator],
'me@test.com',
'desc',
'creator'
)
expect(desc.map((m) => m.id)).toEqual(['creator', 'promoted', 'm'])
const asc = sortMembers(
[member, promoted, creator],
'me@test.com',
'asc',
'creator'
)
expect(asc[0].id).toBe('creator')
})
})
describe('filterBySearch', () => {
@@ -172,7 +216,7 @@ describe('sortPendingInvites', () => {
expect(result[1].id).toBe('l')
})
it('falls back to inviteDate when sortField is joinDate', () => {
it('falls back to inviteDate when sortField is role', () => {
const early = createInvite({
id: 'e',
inviteDate: new Date('2025-01-01')
@@ -181,7 +225,7 @@ describe('sortPendingInvites', () => {
id: 'l',
inviteDate: new Date('2025-06-01')
})
const result = sortPendingInvites([early, late], 'joinDate', 'desc')
const result = sortPendingInvites([early, late], 'role', 'desc')
expect(result[0].id).toBe('l')
})
@@ -203,16 +247,22 @@ describe('sortPendingInvites', () => {
})
const mockToastAdd = vi.fn()
const mockCopyInviteLink = vi.fn()
const mockResendInvite = vi.fn()
const mockShowRemoveMemberDialog = vi.fn()
const mockShowRevokeInviteDialog = vi.fn()
const mockShowCreateWorkspaceDialog = vi.fn()
const mockShowChangeMemberRoleDialog = vi.fn()
const mockShowSubscriptionDialog = vi.fn()
const mockShowInviteMemberDialog = vi.fn()
const mockShowInviteMemberUpsellDialog = vi.fn()
const {
mockMembers,
mockPendingInvites,
mockOriginalOwnerId,
mockIsInPersonalWorkspace,
mockIsWorkspaceSubscribed,
mockTotalMemberSlots,
mockIsInviteLimitReached,
mockPermissions,
mockUiConfig,
mockIsActiveSubscription,
@@ -224,7 +274,11 @@ const {
return {
mockMembers: ref<WorkspaceMember[]>([]),
mockPendingInvites: ref<PendingInvite[]>([]),
mockOriginalOwnerId: ref<string | null>(null),
mockIsInPersonalWorkspace: ref(false),
mockIsWorkspaceSubscribed: ref(true),
mockTotalMemberSlots: ref(0),
mockIsInviteLimitReached: ref(false),
mockPermissions: ref({
canViewOtherMembers: true,
canViewPendingInvites: true,
@@ -240,8 +294,7 @@ const {
showMembersList: true,
showPendingTab: true,
showSearch: true,
showDateColumn: true,
showRoleBadge: true,
showRoleColumn: true,
membersGridCols: 'grid-cols-[50%_40%_10%]',
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
headerGridCols: 'grid-cols-[50%_40%_10%]',
@@ -250,7 +303,10 @@ const {
workspaceMenuDisabledTooltip: null as string | null
}),
mockIsActiveSubscription: ref(true),
mockSubscription: ref<{ tier: string } | null>({ tier: 'PRO' })
mockSubscription: ref<{ tier: string; isCancelled?: boolean } | null>({
tier: 'PRO',
isCancelled: false
})
}
})
@@ -271,11 +327,16 @@ vi.mock('pinia', async (importOriginal) => {
})
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
MAX_WORKSPACE_MEMBERS: 30,
useTeamWorkspaceStore: () => ({
members: mockMembers,
pendingInvites: mockPendingInvites,
originalOwnerId: mockOriginalOwnerId,
totalMemberSlots: mockTotalMemberSlots,
isInviteLimitReached: mockIsInviteLimitReached,
isInPersonalWorkspace: mockIsInPersonalWorkspace,
copyInviteLink: mockCopyInviteLink
isWorkspaceSubscribed: mockIsWorkspaceSubscribed,
resendInvite: mockResendInvite
})
}))
@@ -294,11 +355,17 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
})
}))
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
() => ({
useSubscriptionDialog: () => ({ show: mockShowSubscriptionDialog })
})
)
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: mockIsActiveSubscription,
subscription: mockSubscription,
showSubscriptionDialog: mockShowSubscriptionDialog,
getMaxSeats: (tierKey: string) => {
const seats: Record<string, number> = {
free: 1,
@@ -311,28 +378,13 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
})
}))
vi.mock('@/platform/cloud/subscription/constants/tierPricing', () => ({
TIER_TO_KEY: {
FREE: 'free',
STANDARD: 'standard',
CREATOR: 'creator',
PRO: 'pro',
FOUNDERS_EDITION: 'founder'
}
}))
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
() => ({
useSubscriptionDialog: () => ({ show: vi.fn() })
})
)
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showRemoveMemberDialog: mockShowRemoveMemberDialog,
showRevokeInviteDialog: mockShowRevokeInviteDialog,
showCreateWorkspaceDialog: mockShowCreateWorkspaceDialog
showChangeMemberRoleDialog: mockShowChangeMemberRoleDialog,
showInviteMemberDialog: mockShowInviteMemberDialog,
showInviteMemberUpsellDialog: mockShowInviteMemberUpsellDialog
})
}))
@@ -341,9 +393,13 @@ describe('useMembersPanel', () => {
vi.clearAllMocks()
mockMembers.value = []
mockPendingInvites.value = []
mockOriginalOwnerId.value = null
mockIsInPersonalWorkspace.value = false
mockIsWorkspaceSubscribed.value = true
mockTotalMemberSlots.value = 0
mockIsInviteLimitReached.value = false
mockIsActiveSubscription.value = true
mockSubscription.value = { tier: 'PRO' }
mockSubscription.value = { tier: 'PRO', isCancelled: false }
})
// Lazy import so mocks are in place
@@ -352,49 +408,28 @@ describe('useMembersPanel', () => {
return useMembersPanel()
}
describe('isSingleSeatPlan', () => {
it('is false for personal workspace', async () => {
describe('team plan detection', () => {
it('is on the team plan for a subscribed team workspace', async () => {
const panel = await setup()
expect(panel.isOnTeamPlan.value).toBe(true)
})
it('is off the team plan in a personal workspace', async () => {
mockIsInPersonalWorkspace.value = true
const panel = await setup()
expect(panel.isSingleSeatPlan.value).toBe(false)
expect(panel.isOnTeamPlan.value).toBe(false)
})
it('is true when no active subscription', async () => {
mockIsActiveSubscription.value = false
it('is off the team plan when the team workspace is not subscribed', async () => {
mockIsWorkspaceSubscribed.value = false
const panel = await setup()
expect(panel.isSingleSeatPlan.value).toBe(true)
expect(panel.isOnTeamPlan.value).toBe(false)
})
it('is true for standard tier (1 seat)', async () => {
mockSubscription.value = { tier: 'STANDARD' }
it('caps members at the flat team maximum regardless of tier', async () => {
mockSubscription.value = { tier: 'CREATOR', isCancelled: false }
const panel = await setup()
expect(panel.isSingleSeatPlan.value).toBe(true)
})
it('is false for pro tier (20 seats)', async () => {
mockSubscription.value = { tier: 'PRO' }
const panel = await setup()
expect(panel.isSingleSeatPlan.value).toBe(false)
})
})
describe('maxSeats', () => {
it('returns 1 for personal workspace', async () => {
mockIsInPersonalWorkspace.value = true
const panel = await setup()
expect(panel.maxSeats.value).toBe(1)
})
it('returns tier-appropriate seats', async () => {
mockSubscription.value = { tier: 'CREATOR' }
const panel = await setup()
expect(panel.maxSeats.value).toBe(5)
})
it('returns 1 when no subscription', async () => {
mockSubscription.value = null
const panel = await setup()
expect(panel.maxSeats.value).toBe(1)
expect(panel.maxSeats.value).toBe(30)
})
})
@@ -462,23 +497,29 @@ describe('useMembersPanel', () => {
})
})
describe('handleCopyInviteLink', () => {
it('shows success toast on success', async () => {
mockCopyInviteLink.mockResolvedValue(undefined)
describe('handleResendInvite', () => {
it('resends the invite and shows a success toast', async () => {
mockResendInvite.mockResolvedValue(undefined)
const panel = await setup()
await panel.handleCopyInviteLink(createInvite({ id: 'inv-1' }))
expect(mockCopyInviteLink).toHaveBeenCalledWith('inv-1')
await panel.handleResendInvite(createInvite({ id: 'inv-1' }))
expect(mockResendInvite).toHaveBeenCalledWith('inv-1')
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
expect.objectContaining({
severity: 'success',
summary: 'workspacePanel.toast.inviteResent'
})
)
})
it('shows error toast on failure', async () => {
mockCopyInviteLink.mockRejectedValue(new Error('fail'))
mockResendInvite.mockRejectedValue(new Error('fail'))
const panel = await setup()
await panel.handleCopyInviteLink(createInvite({ id: 'inv-1' }))
await panel.handleResendInvite(createInvite({ id: 'inv-1' }))
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
expect.objectContaining({
severity: 'error',
summary: 'workspacePanel.toast.inviteResendFailed'
})
)
})
})
@@ -491,14 +532,6 @@ describe('useMembersPanel', () => {
})
})
describe('handleCreateWorkspace', () => {
it('calls showCreateWorkspaceDialog', async () => {
const panel = await setup()
panel.handleCreateWorkspace()
expect(mockShowCreateWorkspaceDialog).toHaveBeenCalled()
})
})
describe('handleRemoveMember', () => {
it('calls showRemoveMemberDialog', async () => {
const panel = await setup()
@@ -506,4 +539,222 @@ describe('useMembersPanel', () => {
expect(mockShowRemoveMemberDialog).toHaveBeenCalledWith('mem-7')
})
})
describe('handleChangeRole', () => {
it('opens the change-role dialog when promoting a member', async () => {
const panel = await setup()
panel.handleChangeRole(
createMember({ id: 'mem-7', name: 'Jane', role: 'member' }),
'owner'
)
expect(mockShowChangeMemberRoleDialog).toHaveBeenCalledWith({
memberId: 'mem-7',
memberName: 'Jane',
targetRole: 'owner'
})
})
it('opens the change-role dialog when demoting an owner', async () => {
const panel = await setup()
panel.handleChangeRole(
createMember({ id: 'own-2', name: 'Jane', role: 'owner' }),
'member'
)
expect(mockShowChangeMemberRoleDialog).toHaveBeenCalledWith({
memberId: 'own-2',
memberName: 'Jane',
targetRole: 'member'
})
})
it('is a no-op when the member already has the target role', async () => {
const panel = await setup()
panel.handleChangeRole(createMember({ role: 'member' }), 'member')
expect(mockShowChangeMemberRoleDialog).not.toHaveBeenCalled()
})
})
describe('memberMenuItems', () => {
it('builds a Change role submenu with the current role checked', async () => {
const panel = await setup()
const items = panel.memberMenuItems(createMember({ role: 'member' }))
expect(items.map((i) => i.label)).toEqual([
'workspacePanel.members.actions.changeRole',
'workspacePanel.members.actions.removeMember'
])
const roleItems = items[0].items ?? []
expect(roleItems.map((i) => i.label)).toEqual([
'workspaceSwitcher.roleOwner',
'workspaceSwitcher.roleMember'
])
expect(roleItems.map((i) => i.checked)).toEqual([false, true])
})
it('checks Owner for owner rows', async () => {
const panel = await setup()
const items = panel.memberMenuItems(createMember({ role: 'owner' }))
const roleItems = items[0].items ?? []
expect(roleItems.map((i) => i.checked)).toEqual([true, false])
})
it('routes submenu selection to the change-role dialog', async () => {
const panel = await setup()
const member = createMember({ id: 'mem-9', role: 'member' })
const ownerItem = (panel.memberMenuItems(member)[0].items ?? [])[0]
ownerItem.command?.({
originalEvent: new Event('click'),
item: ownerItem
})
expect(mockShowChangeMemberRoleDialog).toHaveBeenCalledWith(
expect.objectContaining({ memberId: 'mem-9', targetRole: 'owner' })
)
})
it('routes Remove member to the remove dialog', async () => {
const panel = await setup()
const member = createMember({ id: 'mem-9' })
const removeItem = panel.memberMenuItems(member)[1]
removeItem.command?.({
originalEvent: new Event('click'),
item: removeItem
})
expect(mockShowRemoveMemberDialog).toHaveBeenCalledWith('mem-9')
})
})
describe('isOriginalOwner', () => {
it('matches against the store originalOwnerId', async () => {
mockOriginalOwnerId.value = 'creator-1'
const panel = await setup()
expect(panel.isOriginalOwner(createMember({ id: 'creator-1' }))).toBe(
true
)
expect(panel.isOriginalOwner(createMember({ id: 'other' }))).toBe(false)
})
})
describe('single-member visibility gating', () => {
it('hides search and view tabs when the owner is alone', async () => {
mockMembers.value = [
createMember({ role: 'owner', email: 'owner@example.com' })
]
const panel = await setup()
expect(panel.showSearch.value).toBe(false)
expect(panel.showViewTabs.value).toBe(false)
})
it('shows search and view tabs with more than one member', async () => {
mockMembers.value = [createMember(), createMember({ id: '2' })]
const panel = await setup()
expect(panel.showSearch.value).toBe(true)
expect(panel.showViewTabs.value).toBe(true)
})
it('shows view tabs for a lone owner with pending invites', async () => {
mockMembers.value = [
createMember({ role: 'owner', email: 'owner@example.com' })
]
mockPendingInvites.value = [createInvite()]
const panel = await setup()
expect(panel.showViewTabs.value).toBe(true)
expect(panel.showSearch.value).toBe(false)
})
it('shows search for >1 member regardless of tier', async () => {
mockSubscription.value = { tier: 'STANDARD', isCancelled: false }
mockMembers.value = [createMember(), createMember({ id: '2' })]
const panel = await setup()
expect(panel.showSearch.value).toBe(true)
expect(panel.showViewTabs.value).toBe(true)
})
it('hides view tabs when the team workspace is not subscribed', async () => {
mockIsWorkspaceSubscribed.value = false
mockMembers.value = [createMember(), createMember({ id: '2' })]
const panel = await setup()
expect(panel.showViewTabs.value).toBe(false)
expect(panel.showSearch.value).toBe(true)
})
})
describe('invite gating', () => {
it('opens the invite dialog on an active team plan', async () => {
const panel = await setup()
panel.handleInviteMember()
expect(mockShowInviteMemberDialog).toHaveBeenCalled()
expect(mockShowInviteMemberUpsellDialog).not.toHaveBeenCalled()
})
it('opens the upsell dialog when not on a team plan', async () => {
mockIsWorkspaceSubscribed.value = false
const panel = await setup()
panel.handleInviteMember()
expect(mockShowInviteMemberUpsellDialog).toHaveBeenCalled()
expect(mockShowInviteMemberDialog).not.toHaveBeenCalled()
})
it('disables the invite button at the member cap (30)', async () => {
mockTotalMemberSlots.value = 30
const panel = await setup()
expect(panel.isInviteDisabled.value).toBe(true)
expect(panel.inviteTooltip.value).toBe(
'workspacePanel.inviteLimitReached'
)
panel.handleInviteMember()
expect(mockShowInviteMemberDialog).not.toHaveBeenCalled()
})
it('keeps the invite button enabled below the member cap', async () => {
mockTotalMemberSlots.value = 29
const panel = await setup()
expect(panel.isInviteDisabled.value).toBe(false)
expect(panel.inviteTooltip.value).toBeNull()
})
it('disables the invite button at the flat backend member cap', async () => {
mockIsInviteLimitReached.value = true
const panel = await setup()
expect(panel.isInviteDisabled.value).toBe(true)
})
it('disables the invite button when not on a team plan', async () => {
mockIsWorkspaceSubscribed.value = false
const panel = await setup()
expect(panel.isInviteDisabled.value).toBe(true)
})
it('disables the invite button when the team plan is cancelled', async () => {
mockSubscription.value = { tier: 'PRO', isCancelled: true }
const panel = await setup()
expect(panel.isInviteDisabled.value).toBe(true)
panel.handleInviteMember()
expect(mockShowInviteMemberDialog).not.toHaveBeenCalled()
})
it('shows a disabled invite button in a personal workspace', async () => {
mockIsInPersonalWorkspace.value = true
mockPermissions.value = {
...mockPermissions.value,
canInviteMembers: false
}
const panel = await setup()
expect(panel.showInviteButton.value).toBe(true)
expect(panel.isInviteDisabled.value).toBe(true)
})
it('hides the invite button for members without invite permission', async () => {
mockPermissions.value = {
...mockPermissions.value,
canInviteMembers: false
}
const panel = await setup()
expect(panel.showInviteButton.value).toBe(false)
})
})
})

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