Compare commits

...

51 Commits

Author SHA1 Message Date
Matt Miller
414ef7fbae Merge remote-tracking branch 'origin/main' into mattmillerai/fix-customer-create-409
# Conflicts:
#	src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts
2026-07-01 21:13:27 -07:00
Matt Miller
8c49e64682 Merge branch 'main' into mattmillerai/fix-customer-create-409 2026-06-30 21:04:00 -07:00
Matt Miller
fbca7ba220 Merge branch 'main' into mattmillerai/fix-customer-create-409 2026-06-30 13:18:51 -07:00
Matt Miller
13965f44bf fix: drop duplicate launches nav entry from merge 2026-06-30 06:11:35 -07:00
Matt Miller
59b7e118c8 Merge remote-tracking branch 'origin/main' into mattmillerai/fix-customer-create-409
# Conflicts:
#	apps/website/src/data/drops.ts
#	browser_tests/fixtures/utils/objectInfo.ts
#	browser_tests/fixtures/utils/promotedMissingModel.ts
#	package.json
#	src/components/common/DropdownItem.vue
#	src/components/common/FilterDropdown.vue
#	src/components/rightSidePanel/errors/ErrorNodeCard.stories.ts
#	src/components/rightSidePanel/parameters/SectionWidgets.test.ts
#	src/components/sidebar/tabs/NodeLibrarySidebarTabV2.vue
#	src/composables/graph/useErrorClearingHooks.ts
#	src/composables/useEssentialTileNodeDef.ts
#	src/core/graph/subgraph/promotedWidgetTypes.ts
#	src/core/graph/subgraph/resolveConcretePromotedWidget.ts
#	src/locales/he/main.json
#	src/locales/he/nodeDefs.json
#	src/platform/cloud/subscription/composables/useSubscription.ts
#	src/platform/cloud/subscription/utils/creditsProgress.ts
#	src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts
#	src/platform/missingModel/missingModelScan.test.ts
#	src/platform/missingModel/types.ts
#	src/renderer/extensions/vueNodes/composables/useProcessedWidgets.ts
#	src/services/litegraphService.ts
#	src/stores/authStore.test.ts
#	src/stores/authStore.ts
2026-06-30 06:06:09 -07:00
Matt Miller
9b527a275b test: use function declarations for fetchWithCustomerRecovery helpers
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:50:50 -07:00
Matt Miller
ab9f66efc9 test: cover uid-scoped recovery memo and root-anchored customer endpoint
Regression tests for the two review fixes:
- fetchWithCustomerRecovery re-provisions when the active uid changes
  without an auth-state reset (uid-guarded memo).
- a 409 from a path where /customers/ is not the root segment
  (/foo/customers/bar) does not trigger provisioning.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/12751#discussion_r3478407065
https://github.com/Comfy-Org/ComfyUI_frontend/pull/12751#discussion_r3478407069
2026-06-26 15:56:18 -07:00
Matt Miller
e127c6e3a5 fix: anchor isCustomerEndpoint to the path root
pathname.includes('/customers/') also matched paths where /customers/ is
not the root segment (e.g. /foo/customers/x). Anchor with startsWith so
only true /customers/* endpoints trigger 409 recovery.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/12751#discussion_r3478407069
2026-06-26 15:52:45 -07:00
Matt Miller
080293a078 fix: scope recoverMissingCustomer memo to the current uid
recoverMissingCustomer memoized customerRecovery with no uid guard,
relying solely on the onAuthStateChanged listener nulling it between
accounts. A re-auth that only fires onIdTokenChanged (uid changes, no
listener reset) would reuse the prior account's resolved recovery and
skip provisioning for the new account. Mirror the uid guard createCustomer
already applies to its customerCreated write: capture the uid alongside the
memo and start a fresh recovery when it no longer matches the current user.
Same-uid behavior is unchanged (no extra POST on a resolved memo).

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/12751#discussion_r3478407065
2026-06-26 15:51:54 -07:00
Matt Miller
7df9b8b42a Merge branch 'main' into mattmillerai/fix-customer-create-409
Resolve conflicts where main introduced fetchWithUnifiedRemint (401 token
re-mint retry) at the same /customers/* call sites this PR routes through
fetchWithCustomerRecovery (409 missing-customer self-heal):

- fetchWithCustomerRecovery now composes both layers: its initial and retry
  fetches go through fetchWithUnifiedRemint, so customer endpoints keep main's
  401 re-mint behavior while gaining 409 self-heal. Gate matches other call
  sites (isCloud && flags.unifiedCloudAuthEnabled); flag-OFF degrades to a
  single plain fetch, identical to pre-merge behavior.
- All /customers/* call sites (authStore balance/credit/billing, subscription
  status/checkout) call fetchWithCustomerRecovery instead of the raw
  fetchWithUnifiedRemint; dropped now-unused fetchWithUnifiedRemint/
  useFeatureFlags imports in the subscription files.
- createCustomer keeps main's optional payload param and this PR's
  sessionUserId snapshot + uid guard.
2026-06-26 15:49:17 -07:00
Christian Byrne
63bda483b9 refactor: localize system stats headers and fix PyTorch casing (#12253)
## Summary
Localizes the 12 system stats column headers via vue-i18n and fixes the
`Pytorch` → `PyTorch` casing typo.

## Changes
- **src/locales/en/main.json**: Add 11 i18n keys under `g.systemStats*`
namespace
- **src/components/common/SystemStatsPanel.vue**: 
  - Import `useI18n` and use `t()` for column headers
  - Change `header` field to `headerKey` in ColumnDef type
  - Fix PyTorch casing (was `Pytorch Version`)

## Context
Follow-up to PR #11816 per review comment.

## Test plan
- [x] Column headers render correctly
- [x] Copy System Info includes localized headers
- [ ] Verify other locales can override (out of scope - EN only for now)

Closes #11870

🤖 Generated with [Claude Code](https://claude.com/claude-code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12253-refactor-localize-system-stats-headers-and-fix-PyTorch-casing-3606d73d36508134af99f7ca4f9c6593)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-26 15:30:28 -07:00
Yosef Chai
086b72f709 feat(i18n): add Hebrew (he) language support (#12721)
## Summary

Adds full Hebrew (`he` / עברית) localization for the ComfyUI frontend
UI, registered the same additive way as the existing RTL locales (`ar`,
`fa`).

## Changes

- **What**:
- New `src/locales/he/main.json`, `commands.json`, and `settings.json` —
complete, human-reviewed Hebrew translations.
- Exact key parity with `en` (3524 / 125 / 235 keys), verified
programmatically.
- All `{interpolation}` placeholders and `|`-separated plural forms are
preserved (same segment counts as `en`).
- Established technical terms are kept in English (API, GPU, VAE, CLIP,
LoRA, ControlNet, Civitai, Hugging Face, flux, Nodes 2.0, …).
- `dataTypes` and `nodeCategories` are kept as verbatim English
identifiers; option **keys** in
`settings.json`/`menuLabels`/`contextMenu` are left untouched (only
their display values are translated).
- `src/locales/he/nodeDefs.json` is an empty object on purpose, so node
definitions fall back to English and get auto-populated by the release
i18n workflow (per the locale CONTRIBUTING guide).
- Registered `he: { text: 'עברית', loaders: loadersFor('he') }` in
`src/locales/localeConfig.ts` (which automatically adds it to the
Settings → Language dropdown and `SUPPORTED_LOCALE_OPTIONS`).
- Added `he` to `outputLocales` in `.i18nrc.cjs`, plus a Hebrew glossary
block for the CI translator.

## Review Focus

- This follows the approach of #7876 (Persian/Farsi). Like the existing
`ar`/`fa` locales, it translates UI text only and does **not** introduce
RTL layout (`dir="rtl"`) — the app does not currently apply RTL layout
for any locale. I'm happy to follow up with proper RTL layout support in
a separate PR if that's wanted.
- Recurring-term glossary used for consistency: node = צומת, workflow =
תהליך עבודה, queue = תור, widget = פקד, subgraph = תת-גרף, canvas =
קנבס, bypass = עקיפה, prompt = פרומפט.
- Native-speaker review is very welcome. cc translation maintainers
@Yorha4D @KarryCharon @DorotaLuna @shinshin86

## Screenshots

Text-only locale addition — no UI/layout changes. After this change,
**Settings → Language** lists **"עברית"**, and selecting it renders the
UI in Hebrew (untranslated node definitions fall back to English).

---------

Co-authored-by: Yosef Chai <192742853+yosef-chai@users.noreply.github.com>
Co-authored-by: christian-byrne <abolkonsky.rem@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 21:58:18 +00:00
balpreetgrowthnatives
bb637bddc9 fix: prevent payment routes from being indexed (#12706)
## Summary

Allow crawler access to `/payment/` routes in robots.txt so search
engines can read the `noindex` tag, and forcefully inject the
`x-robots-tag: noindex` header via `vercel.json`.

## Changes

- **What**: Removed `Disallow: /payment/` from `robots.txt` and added
rules to `vercel.json` applying `x-robots-tag: noindex` to
`/payment/(.*)` and `/zh-CN/payment/(.*)` routes.

## Review Focus

- The configurations in `vercel.json` apply to both English and
localized payment routes (`/zh-CN/payment/(.*)`).

---------

Co-authored-by: nav-tej <36310614+nav-tej@users.noreply.github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-06-26 15:11:12 -07:00
Christian Byrne
b86a418d16 fix: give backport push a token that can update workflow files (#13038)
## Summary

The `PR Backport` workflow silently fails for any PR that also modifies
a file under `.github/workflows/**`.

## Root cause

The `backport` job checks out with the default `GITHUB_TOKEN` and reuses
those persisted credentials for `git push`. GitHub refuses to let that
token create or update workflow files:

```
! [remote rejected]  backport-12804-to-core-1.45 -> backport-12804-to-core-1.45
  (refusing to allow a GitHub App to create or update workflow
   `.github/workflows/ci-tests-e2e.yaml` without `workflows` permission)
error: failed to push some refs
```

The cherry-pick itself succeeds — only the push is rejected. And because
the `run:` step inherits `set -e`, the loop aborts before writing the
`failed=` output, so the "Comment on failures" step (`if: failure() &&
steps.backport.outputs.failed`) posts nothing. The result is a red job
with no explanation on the PR.

## History

PR #12804 touched `.github/workflows/ci-tests-e2e.yaml` and
`.github/actions/setup-frontend/action.yaml`. Its backport run
([27788259837](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27788259837/job/82230406910))
failed exactly this way: cherry-pick clean on every target, push
rejected on the workflow file. All four backports (#12966, #12967,
#12968, #12969) had to be created manually.

## Changes

Check out with `PR_GH_TOKEN` (already used by the Create-PR step) so the
push carries `workflow` scope.

> [!IMPORTANT]
> `PR_GH_TOKEN` must have **workflow** write permission for this to take
effect. If it does not, the secret needs that scope added.

## Follow-up (not in this PR)

The push failure aborts the whole job under `set -e` with no PR comment.
Even with the token fixed, a push rejected for another reason (branch
protection, etc.) would still fail silently. Wrapping the push so a
single-target failure is recorded as a `push-failed` reason and reported
via the existing failure-comment step would make the workflow degrade
gracefully.

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-26 20:37:02 +00:00
jaeone94
7ab1c72180 Redesign missing model detection contract for promoted subgraph widgets (#13059)
## Summary

Redesign missing-model detection for ADR 0009 promoted subgraph widgets
so candidates are created from the widget value the user can actually
edit, while still using the concrete interior widget as the
schema/options source.

## Why This PR Exists

This PR comes from the follow-up missing-model detection work for the
ADR 0009 / 1.46 subgraph widget changes introduced by
[#12197](https://github.com/Comfy-Org/ComfyUI_frontend/pull/12197).

[#12197](https://github.com/Comfy-Org/ComfyUI_frontend/pull/12197)
intentionally changed promoted subgraph widgets to be represented
through subgraph input links. After that change, the promoted widget on
the host `SubgraphNode` is the editable value owner, and linked interior
widgets are no longer guaranteed to mirror that value.

The old missing-model contract still treated the concrete interior node
widget as the effective source of truth in subgraph cases:

- recursive scans entered the subgraph and scanned the interior widget
value;
- candidates were keyed by the interior node/widget identity;
- the parent subgraph host mostly received propagated
highlight/navigation behavior;
- subgraph container widgets were not treated as first-class candidate
sources.

That contract breaks after ADR 0009. A user can resolve a missing model
by changing the promoted host widget to an installed model, while the
linked interior widget can still hold the old stale value. If detection
keeps scanning the linked interior value, entering the subgraph or
reloading the workflow can re-create a false missing-model error that no
longer corresponds to the value the user can edit.

ADR 0009 also means the same subgraph definition can be reused by
multiple `SubgraphNode` hosts. Once missing-model detection moves from
the interior definition widget to the promoted host widget, the selected
value is no longer a property of the shared definition alone. It is a
property of a specific host instance. That makes the old interior-node
identity insufficient for mode changes, removal handling, and re-scan
behavior: a single interior leaf definition can be reachable through
multiple host execution paths, and only the affected host path should
add, remove, or restore a candidate.

This PR also builds on
[#12990](https://github.com/Comfy-Org/ComfyUI_frontend/pull/12990),
which narrowed workflow-level `models[]` and embedded model data to
metadata enrichment only. Together, the intended boundary is:

- live widgets create missing-model candidates;
- workflow/root-level `models[]` and node metadata only enrich
candidates that already came from a live widget;
- promoted widget values are read from the editable host widget, not
inferred from stale interior `widgets_values`.

## Changes

- **What**:
  - Introduces promoted-widget scan targets that split:
    - host promoted widget value and candidate identity;
    - concrete leaf widget/node definition data.
- Scans the outermost unlinked promoted widget on a `SubgraphNode` host
as the selected value owner.
- Skips linked interior widgets as candidate sources, preventing stale
linked widget values from producing duplicate or false missing-model
candidates.
- Resolves the concrete leaf widget for combo options, asset-widget
support, node type, directory lookup, and embedded metadata enrichment.
- Keys promoted missing-model candidates by the host execution id and
host promoted widget name.
- Adds `sourceExecutionId` to promoted candidates so liveness still
follows the concrete source execution path, including nested inactive
subgraph containers.
- Uses source-scope activity for pipeline filtering and async
verification, while keeping highlight/store/clearing identity
host-keyed.
- Removes host-keyed promoted candidates when their source execution
scope is removed or bypassed.
- Re-scans ancestor subgraph hosts when an interior source path is
un-bypassed, so host-keyed promoted errors can reappear correctly.
- Handles shared subgraph definitions by deriving promoted source paths
from the concrete host instance path, rather than treating the shared
definition node id as globally unique.
- Shares promoted source resolution between Vue node processing and the
right-side panel to avoid drift.
- Aligns missing-model clearing across Vue node widgets, legacy canvas
widgets, and right-side panel Parameters/Nodes section widgets.
- Adds unit coverage for scan identity, source-scope liveness, dynamic
mode changes, source-scope removal, shared-definition host isolation,
and right-side panel clearing.
- Adds nested promoted-widget E2E coverage for OSS and Cloud flows
across Vue, Parameters tab, and legacy widget surfaces.
- **Breaking**: None expected.
- **Dependencies**: None.

## New Detection Contract

A missing-model candidate is created from an unlinked final editable
value owner.

That value owner can be:

- a normal node model widget; or
- the outermost promoted model widget displayed on a `SubgraphNode`
host.

For promoted widgets:

- the host promoted widget supplies the selected value;
- the host execution id and host widget name are the candidate identity;
- the concrete leaf widget supplies definition data such as combo
options and asset-browser support;
- the concrete source execution path is retained as `sourceExecutionId`
for liveness only;
- linked interior widgets are skipped as candidate sources because their
values are not authoritative when driven by a promoted input.

For a nested chain:

`Outer promoted widget A -> inner promoted widget B -> concrete widget
C`

only `A` creates the candidate. `B` and `C` are linked along the
promoted-input path and are skipped as selected-value sources, while `C`
still provides the concrete widget definition used to evaluate `A`.

## Shared Definition And Source-Scope Liveness

ADR 0009 promoted widgets make subgraphs behave more like reusable
definitions with host-owned inputs. Two host `SubgraphNode`s can point
at the same interior subgraph definition while carrying different
promoted widget values. In that shape, the missing-model candidate must
be keyed to the editable host surface, but the activity check cannot use
the host id alone.

For example, if two outer hosts share the same nested subgraph
definition, one host can select a valid model while the other still
selects a missing model. The result should be one missing-model
reference, not a single definition-level error and not two errors after
one host is fixed. Likewise, bypassing or un-bypassing an interior
nested container should affect only the host execution paths that
actually pass through that container.

This PR therefore separates two concepts:

- **candidate identity**: host execution id + host promoted widget name,
used for storage, highlight, navigation, and clearing;
- **candidate liveness**: concrete source execution path, used for
scan-time activity checks, pipeline filtering, async verification,
source-scope removal, and re-exposure after mode changes.

That separation is the reason this PR updates more than the scan itself.
Moving the detection target to the subgraph host also requires the
mode-change and removal paths to understand that a host-keyed candidate
can be invalidated by a descendant source path, and can need to be
restored by re-scanning an ancestor host when an interior source path
becomes active again.

## Review Focus

Please review the identity split carefully:

- candidate/store/highlight/clearing identity should remain host-keyed
for promoted widgets;
- liveness should use `sourceExecutionId` when present, falling back to
`nodeId` for normal candidates;
- scan-time activity checks should account for the source node itself
and all ancestor subgraph containers;
- source-scope removal should remove host-keyed candidates whose
concrete source path was removed or bypassed;
- un-bypassing an interior source path should re-scan affected ancestor
subgraph hosts so host-keyed candidates can reappear;
- shared subgraph definitions should not merge errors across different
host instances;
- linked interior widgets should not produce their own missing-model
candidates;
- asset-browser eligibility should be resolved from the concrete leaf
node type and widget name, not the synthetic subgraph host type;
- right-side panel edits should clear host missing-model errors and
source validation errors consistently.

The E2E matrix intentionally keeps nested promoted workflows only.
Nested promoted widgets cover the same editable host path as single
promoted widgets while also exercising the `A -> B -> C` chain that can
break source-scope liveness and re-scan behavior. The nested fixture
also includes multiple host instances that share the same subgraph
definition, so it verifies that fixing one host does not accidentally
clear or suppress another host's missing-model candidate. Direct/single
promoted behavior is still covered at the unit level.

## Non-Goals

- This PR does not reintroduce workflow-level `models[]` candidate
creation.
- This PR does not infer selected model values from `widgets_values`.
- This PR does not synchronize linked interior widget values back from
promoted host widgets.
- This PR does not redesign missing-media scanning; missing media still
skips subgraph containers and remains keyed by concrete interior paths.
The shared async post-verification active-scope filter is intentionally
stricter, so a pending missing-media candidate is no longer surfaced if
its own node is bypassed or removed while verification is in flight.

## Validation

- `pnpm exec vitest run
src/components/rightSidePanel/parameters/SectionWidgets.test.ts
src/platform/missingModel/missingModelScan.test.ts
src/composables/graph/useErrorClearingHooks.test.ts
src/platform/missingModel/missingModelPipeline.test.ts
src/platform/missingModel/missingModelStore.test.ts
src/utils/graphTraversalUtil.test.ts
src/composables/graph/useGraphNodeManager.test.ts
src/renderer/extensions/vueNodes/composables/useProcessedWidgets.test.ts
--reporter=dot`
  - 8 files passed, 294 tests passed.
- `pnpm exec vitest run
src/platform/missingModel/missingModelScan.test.ts
src/core/graph/subgraph/resolveConcretePromotedWidget.test.ts
src/components/rightSidePanel/parameters/SectionWidgets.test.ts`
  - 3 files passed, 71 tests passed.
- `pnpm typecheck`
- `pnpm typecheck:browser`
- `pnpm format:check`
- targeted ESLint for changed production/unit/E2E files
- `git diff --check`
- `pnpm build`
- `pnpm build:cloud`
- OSS affected E2E on the 8188 build:
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:8188 pnpm
exec playwright test
browser_tests/tests/propertiesPanel/errorsTabModeAware.spec.ts
--project=chromium --grep "Changing an OSS .*promoted|Refreshing a
resolved promoted|Reloading a resolved nested"`
  - 5 passed.
- Cloud affected E2E on the 8188 cloud build:
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:8188 pnpm
exec playwright test
browser_tests/tests/propertiesPanel/errorsTabCloudMissingModels.spec.ts
--project=cloud --grep "Changing a Cloud .*promoted"`
- 2 passed; the Cloud legacy promoted asset-modal case still fails until
[#13075](https://github.com/Comfy-Org/ComfyUI_frontend/pull/13075) is
merged.
- Full OSS `errorsTabModeAware.spec.ts` on the 8188 build:
- 23 passed; 3 existing paste/clipboard cases failed before the promoted
subgraph section with node count remaining at 1 after
`clipboard.paste()`.
- Commit hooks ran `oxfmt`, `oxlint`, `eslint`, `pnpm typecheck`, and
browser typecheck where applicable.
- Pre-push hook ran `pnpm knip --cache`.

## Screenshots

Before


https://github.com/user-attachments/assets/6380c1da-1d92-4b70-888e-3ade572c4b5b

After


https://github.com/user-attachments/assets/4cfc24d6-3dc3-4e36-9b31-72fea6b3d9d5
2026-06-26 19:38:12 +00:00
imick-io
cd979432a9 feat(website): add /drops landing page (livestream hero + drops grid) (#13053)
## Summary

Adds the `/drops` landing page in English and Simplified Chinese, ahead
of the 2026-06-24 livestream. The page is composed of a livestream-gated
hero, a 10-card drops grid, a subscribe banner, and a closing CTA.
Production media for the hero and several cards is wired in; the rest
fall back to placeholders.

## Changes

- **New `/drops` page** (`pages/drops.astro`, `pages/zh-CN/drops.astro`)
plus en/zh-CN translations.
- **`HeroLivestream01` block** — renders the rotating-logo video before
the stream window, swaps to a YouTube nocookie iframe between
`startDateTime` and `endDateTime`. SSR stays deterministic on the logo
and only flips to the embed after client hydration. Takes
`youtubeVideoId` directly (no URL parsing).
- **Drops grid** — `data/drops.ts` defines the 10 cards with `imageFor`
/ `videoFor` media helpers, badges, and per-locale
title/description/href. `DropCard.vue` renders an image or autoplaying
muted video inside the new `Card` primitive set (`Card`, `CardHeader`,
`CardTitle`, `CardDescription`, `CardContent`, `CardFooter`).
- **Supporting blocks** — `SubscribeBanner` uses the Button `link`
variant with an animated hover underline; closing `CtaCenter01` is
extended with a terms link. `resolveRel` is shared from `utils/cta.ts`
instead of duplicated per block.
- **Visual extension to `HeroLivestream01.visual`** — discriminated
`image | video` union so the hero can render a looping video.
- **E2E** — `e2e/drops.spec.ts` covers both locales (hero + grid render,
locale-correct links).

## Review Focus

- `youtubeVideoId` is currently a `nlLZfNIqF8M` placeholder;
`startDateTime`/`endDateTime` are placeholders too — see TODO in
`HeroSection.vue`. These need to be swapped to the production stream +
window before launch.
- Several drop cards still point at placeholder destinations (e.g. Comfy
MCP at `/mcp`) — TODO noted in `drops.ts`.
- Drop card media is a mix of images and short MP4s served from
`media.comfy.org`. The videos autoplay muted/looped with
`preload="metadata"`.

## Test plan

- [ ] `pnpm --filter website dev` → visit `/drops` and `/zh-CN/drops`;
confirm hero video plays, grid renders all 10 cards, video cards loop,
subscribe banner link styled correctly
- [ ] Temporarily set `startDateTime` to the past and `endDateTime` to
the future; confirm hero swaps to the YouTube iframe after hydration
- [ ] `pnpm --filter website test:e2e drops.spec.ts`
- [ ] `pnpm typecheck:website`

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-26 19:09:04 +00:00
Alexis Rolland
e8c41158d7 ci: Update CLA allowlist for GitHub Actions (#13169)
## Summary

- Update CLA check `allowlist` to add `GitHub Action` ,
`action@github.com`, `Glary-Bot`
- Reorder list alphabetically
2026-06-26 11:19:31 -07:00
AustinMroz
19622ac3b9 Essentials tab redesign (#12744)
Subsumes #12304 

Redesigns the Essentials tab to be frontend designed with more
accessible icons and tighter organization.
<img width="381" height="1345" alt="image"
src="https://github.com/user-attachments/assets/193f7f5f-20c8-4bf0-8304-ec2c990186d0"
/>

---------

Co-authored-by: comfydesigner <comfydesigner@users.noreply.github.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-26 17:57:58 +00:00
Alexander Brown
41b64480c3 docs: retarget ECS architecture docs to dedicated stores over a single World (#13039)
## Summary

Realign the ECS architecture docs and ADR 0008 with the shipped
direction (PR #12617): entity data lives in dedicated Pinia stores keyed
by string IDs, rather than one unified \"World\" registry addressed by
branded entity IDs.

## Changes

- **What**: Docs-only. Retarget the `docs/architecture/` set + ADR 0008
+ agent guidance from the single-World/branded-`*EntityId` model to the
dedicated-store model that PR #12617 actually shipped
(`widgetValueStore` keyed by `WidgetId`, `layoutStore`,
`nodeOutputStore`, `domWidgetStore`, `subgraphNavigationStore`,
`previewExposureStore`).
- `AGENTS.md` + `.agents/checks/adr-compliance.md`: point entity-data
guidance at dedicated stores; fix the inverted `world.getComponent`
compliance check (it flagged correct store-based code).
- `ADR 0008`: dated amendment note (stays Proposed); rewrite the World
section → dedicated stores, Branded ID design (`WidgetId` composite
string), migration strategy, render-loop, consequences, notes.
- `proto-ecs-stores.md`: flip the \"Unified World / branded IDs\"
framing from gap-to-close → target; replace the deleted
`PromotedWidgetViewManager` with the `input.widgetId` store-backed
model; fix key formats and store count.
- `ecs-target-architecture.md` / `ecs-lifecycle-scenarios.md` /
`ecs-migration-plan.md`: reframe all `world.*` APIs and `*EntityId`
brands to per-store APIs + string keys; mark already-shipped migration
phases done.
- `subgraph-boundaries-and-promotion.md` / `entity-interactions.md` /
`entity-problems.md`: scope-tagged store entries; swap removed
`PromotionStore` for `previewExposureStore`.
- `appendix-critical-analysis.md`: post-pivot status banner + resolution
notes on the critiques the pivot vindicated; still-open gaps (extension
callbacks, atomicity, Y.js↔ECS) left live.
- `appendix-ecs-pattern-survey.md`: supersede banner; keep the external
library survey (§1).
- Delete obsolete `ecs-world-command-api.md` (its command-pattern
argument folded into ADR 0008).
- **Breaking**: None (documentation only).

## Review Focus

- ADR 0008 stays **Proposed** with an amendment note rather than a new
superseding ADR — confirm that's the preferred mechanism vs. a fresh
ADR.
- Numeric per-kind brands (`NodeEntityId`, `LinkEntityId`, …) are
retained in ADR 0008 but explicitly marked aspirational/unshipped; only
`WidgetId` (composite string) reflects shipped code.
- `appendix-ecs-pattern-survey.md` §2–§4 are kept under a supersede
banner as historical record (they describe the deleted `src/world/`
substrate) rather than rewritten — confirm that's preferred over
deletion.
- Net −384 lines; no code or test changes.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:22:36 +00:00
AustinMroz
5c0bafe998 Ensure dynamic combo children cleanup state (#13073)
#12617 introduced a regression in Dynamic Combos. If two options have
child widgets of the same name (such as `bit_depth` on `Save Image
(Advanced)`), then widget state would be incorrectly shared between the
two widgets.

This is resolved by having removed widgets also delete their state.

There was previous interest in having widgets of this type keep state
when valid. This interest remains, but will require a more controlled
intentional implementation in the future.

Since the bit depth options on `Save Image (Advanced)` could potentially
be expanded in the future, this PR specifically adds a new devtools node
for testing with.

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-26 01:08:06 +00:00
Comfy Org PR Bot
26166d3128 1.47.5 (#13166)
Patch version increment to 1.47.5

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-06-26 00:51:57 +00:00
AustinMroz
94499c5cff When dragging vue nodes, also drag reroutes (#12885)
`selectedItems` was being filtered to nodes and groups. Since no special
behaviour is being performed on groups, the 'move groups' code is
relaxed to instead 'move all non-node selected items'.
2026-06-26 00:11:48 +00:00
Comfy Org PR Bot
99dd27cd8b 1.47.4 (#13083)
Patch version increment to 1.47.4

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-06-26 00:01:05 +00:00
Christian Byrne
97c59f3adb refactor(litegraph): delete dead onGetNodeMenuOptions, deprecate onBeforeChange (#12230)
Stacks on #12228. Part of the LGraph dead-hook cleanup per AUDIT-LG.9.

## What

- `LGraph.onGetNodeMenuOptions` — **deleted** (field + dispatcher in
`LGraphCanvas.getNodeMenuOptions`). Zero ecosystem consumers.
- `LGraph.onBeforeChange` — **deprecated, not deleted**. The field and
the dispatch in `LGraph.beforeChange()` are kept, but assigning a
handler now emits a one-time `warnDeprecated` nudging migration to
`LGraphCanvas.onBeforeChange`.

## Why onBeforeChange is preserved

The W2F-1 re-audit found `bmad4ever/ComfyUI-Bmad-DirtyUndoRedo` assigns
`app.graph.onBeforeChange = fn` (the listener-assignment pattern).
Deleting the field outright would silently turn those handlers into
no-ops. Keeping it as a deprecated shim preserves backward compatibility
during a grace period while signaling the intended replacement.

`onAfterChange` is untouched (so `BennyKok/comfyui-deploy`'s
`onAfterChange` wrapper keeps working). `LGraphCanvas.onBeforeChange`
remains a separate field, and the canvas dispatch chain
`this.canvasAction((c) => c.onBeforeChange?.(this))` is unchanged.

## Tests

`LGraph.test.ts` covers the shim: the assigned listener is still
invoked, the deprecation warning fires when used, and no warning fires
when no listener is assigned.

## Sequencing

- Stacks on #12228
- Sequences behind Alex's Phase B (#11939, #11811)

---------

Co-authored-by: Connor Byrne <c.byrne@comfy.org>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-25 16:50:20 -07:00
Dante
95522a7981 feat(billing): align workspace Plan & Credits panel to DES-186 (FE-768) (#12761)
## Summary

Aligns the Settings ▸ Workspaces **Plan & Credits** panel to DES-186:
state-driven subtitle, team/personal header variants, design perks, and
a shared footer help bar. Stacked on FE-964 (#12734), which owns the
CreditsTile content.

## Changes

- **What**:
- Subtitle per design variants (Figma 3255-21472): Active → "Renews on
{date}", Ending → "Ends on {date}". `subscription_status: 'scheduled'`
falls back to the Active treatment — the facade exposes no
scheduled-plan target/date fields yet, so "Changes to {plan} on {date}"
cannot be rendered (template reserved in i18n-ready form; see Linear
note).
- Team-active header: plan name "Team" + seat-aware workspace total
(`seat_summary.total_cost_cents` from `/billing/plans`) as "$X USD /
mo"; the Next-month-invoice card reads the same computed so the two
can't disagree. Per-member tier price + "USD / mo / member" kept as the
plans-unresolved fallback.
- CTAs per design + designer annotation: "Manage billing" + "Change
plan" on a team plan, "Upgrade plan" on personal.
- Team perks (Figma 2993-14789): "Your plan includes everything in
**Pro**, plus:" (i18n-t, plan name emphasized) + invite members /
concurrent runs / shared credit pool / role-based permissions.
- Personal no-subscription variant (Figma 2993-14604): "Free · $0 USD /
mo" header + primary Subscribe CTA + "What's included:" with the
max-runtime perk → "10 min max runtime" (`subscription.maxDuration.free`
set to 10 min per DES 3253-16079).
- Footer help bar (Learn more / Partner Nodes pricing table / Message
support / Invoice history) extracted into `SubscriptionFooterLinks.vue`,
shared by workspace + legacy panels; new surface-specific key for
"Partner Nodes pricing table".
- **Breaking**: none.

## Review Focus

- Seat-aware price source (`currentPlan.seat_summary.total_cost_cents`)
vs per-member fallback — fixture locks $320 vs $80.
- `'scheduled'` → Active fallback (adapter-level test in
`useWorkspaceBilling.test.ts`).
- Free-state perk copy: `subscription.maxDuration.free` set to **10
min** per DES 3253-16079 (design confirmed; was 30 min).
- Free-state overflow (`⋯`) button intentionally omitted: the only
existing menu item (Cancel Subscription) doesn't apply to a free plan.

Linear:
[FE-768](https://linear.app/comfyorg/issue/FE-768/updates-to-workspaces-tab-of-settings)
(Plan & Credits half; Members invite UI ships in #12759)

## Screenshots

Captured on `local.comfy.org` dev (cloud-prod backend, authenticated
session). Team and free states use client-side API stubs (XHR-level for
`/api/billing/*` + `/api/workspace/*`, fetch-level for legacy
`/customers/*`) since the test workspaces are unsubscribed;
personal-active rows are real account data.

| State | Before (FE-964 base) | After (this PR) |
|---|---|---|
| Team plan — active | <img width="400" alt="before: Pro, $100
USD/mo/member, Renews, Manage Payment/Upgrade Plan"
src="https://github.com/user-attachments/assets/622a9a27-1875-4c08-92b7-9e43a8067c59"
/> | <img width="400" alt="after: Team, $300 USD/mo, Renews on, Manage
billing/Change plan, design perks"
src="https://github.com/user-attachments/assets/adb0f767-d508-4455-ad8e-ee2d6ac419dc"
/> |
| Team plan — ending (cancelled) | <img width="400" alt="before: Expires
Jul 10, 2026"
src="https://github.com/user-attachments/assets/cb4bb978-4e8b-4372-8ecc-7265f477a828"
/> | <img width="400" alt="after: Ends on Jul 10, 2026"
src="https://github.com/user-attachments/assets/5466b99a-d016-4eba-a60f-99b87bd2693e"
/> |
| Personal — active (real data) | <img width="400" alt="before: Renews
Mar 10 2027, Manage Payment"
src="https://github.com/user-attachments/assets/34018400-930b-4147-bd0d-398fb4d159ee"
/> | <img width="400" alt="after: Renews on Mar 10 2027, Manage billing"
src="https://github.com/user-attachments/assets/87af7fa7-28cf-4cdf-b9a2-158b2a6eb979"
/> |
| Personal — no subscription | <img width="400" alt="before: generic
not-on-a-subscription prompt"
src="https://github.com/user-attachments/assets/7ee5b36e-1e07-4630-a9b7-680f4fad349b"
/> | <img width="400" alt="after: Free $0 USD/mo header + Subscribe +
What's included"
src="https://github.com/user-attachments/assets/1eda1791-e3de-46c3-bb69-df0365545211"
/> |



---

## Perk descriptions — designer QA (DES `3253-16079`)

CDP-verified the Plan & Credits **perk list** per current plan against
Figma. Captured at the full 1280px settings layout (width fix: #12849;
the perk text/content is identical at the current 960px).

| Plan | "Includes" header | Perks shown |
|---|---|---|
| Free (no subscription) | What's included: | 10 min max runtime |
| Personal — Pro | Your plan includes: | 1 hr max run duration · RTX
6000 Pro (96GB VRAM) · Add more credits whenever · Import your own LoRAs
|
| Team | Your plan includes everything in **Pro**, plus: | Invite
members · Members can run workflows concurrently · Shared credit pool
for all members · Role-based permissions |

**Free — Figma `3253-16079` (left) vs implementation (right):**

| Figma | Implementation |
|---|---|
| <img width="480" alt="Figma DES 3253-16079 free settings"
src="https://github.com/user-attachments/assets/cfe79570-f3ba-4627-a8eb-348b9158f6ac"
/> | <img width="480" alt="App free settings — What's included: 10 min
max runtime"
src="https://github.com/user-attachments/assets/d89898e4-d819-486c-9b4f-c2fd61916783"
/> |

**Personal — Pro:**

<img width="640" alt="App personal Pro — Your plan includes"
src="https://github.com/user-attachments/assets/adc2fd9f-d249-469f-b947-1ec8f674cbb0"
/>

**Team:**

<img width="640" alt="App team — Your plan includes everything in Pro,
plus"
src="https://github.com/user-attachments/assets/e7378067-11a2-411b-b37b-98c8aecb82b1"
/>

Open items (design):
- Free perk now reads **"10 min max runtime"**
(`subscription.maxDuration.free` set to 10 min) per Figma `3253-16079` —
 applied in this PR.
- Personal-plan perk **stacking** (show lower-tier perks under the
current tier) is an unresolved Figma thread on this node — not
implemented.

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-25 23:19:35 +00:00
Wei Hai
f5f1e69cd8 feat(auth): Cloudflare Turnstile on email signup (origin-gated) (#12924)
Adds a Cloudflare Turnstile widget to the email/password signup form
(web + desktop). The frontend renders the widget and attaches its token
to the signup request; the verification decision is made server-side.

## Design — config-driven, no origin sniffing

* The widget renders **iff** the `signup_turnstile` mode is `shadow` or
`enforce` **and** a `turnstile_sitekey` is present — both delivered via
cloud remote config. OSS / local builds receive no remote config, so it
never renders. Gating is a pure `isTurnstileEnabled(mode, siteKey)`; an
unknown mode normalizes to `off`.
* Submit is blocked only in **enforce**; **shadow** never blocks.
* The token is sent as `turnstile_token` (snake_case, optional) on the
customer-creation request.
* **OAuth** never renders the widget or sends a token (federated
providers are exempt).

## Behavior

* **Decision is server-side** — the frontend only renders the widget and
attaches the token; the backend verifies it and decides allow/block.
* **Mode-driven** — `off` (no-op) / `shadow` (render + attach, never
blocks) / `enforce` (blocks submit until solved).
* **Config-gated** — no `isCloud`/origin check in the client; the widget
is driven purely by the presence of the mode flag + sitekey in remote
config.
* **Fail-safe to off** — an unknown/missing mode or a missing sitekey
resolves to "don't render", so the feature is a no-op until both are
configured.
* The sitekey is a public, client-side value delivered per environment
via remote config; in dev it falls back to Cloudflare's always-pass test
sitekey.

## Files

New: `config/turnstile.ts`, `composables/auth/useTurnstile.ts` (+ test),
`composables/auth/turnstileScript.ts`,
`components/dialog/content/signin/TurnstileWidget.vue`. Edited:
`SignUpForm.vue`, `SignInContent.vue`, `useAuthActions.ts`,
`authStore.ts` (+ test), `remoteConfig/types.ts`,
`locales/en/main.json`.

## Flow

```mermaid
sequenceDiagram
    actor U as User
    participant FE as Signup form
    participant CF as Cloudflare Turnstile
    participant API as Backend signup API

    Note over FE: renders only when mode is shadow or enforce<br/>and a sitekey is present
    U->>FE: open email/password signup
    FE->>CF: load widget with sitekey
    CF-->>U: challenge (usually invisible)
    U-->>CF: solve
    CF-->>FE: token (single-use, short-lived)
    U->>FE: submit
    FE->>API: signup request with turnstile_token
    Note over API: verifies the token server-side and<br/>decides allow/block (shadow never blocks)
    API-->>FE: allowed, or blocked in enforce
```

## Rollout

Config-driven and a no-op until enabled:

1. **Merge + deploy** the FE — no visible change while the mode is `off`
/ no sitekey.
2. **Set** the `turnstile_sitekey` in remote config per environment.
3. **`signup_turnstile=shadow`** — the widget renders and attaches the
token; the server observes and never blocks.
4. → **`enforce`** — the FE blocks submit until the challenge is solved.

Kill switch: set the mode back to `off` and the widget stops rendering.

## Refactor: shared script loader

The Turnstile script loader was extracted to
`utils/loadExternalScript.ts` (`createScriptLoader`) and now also backs
the existing Typeform embed loader, removing duplicated
singleton/timeout/cleanup logic. Minor behavioral change: when a
matching `<script>` tag already exists in the DOM, the loader polls for
the global to become ready instead of attaching a `load` listener (which
may have already fired).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-25 23:03:48 +00:00
Simon Pinfold
c202e3cc2d Allow Glary Bot through CLA (#13149)
## Summary

- Add `Glary Bot` to the CLA Assistant allowlist.

## Context

PR #13146 is blocked because its commit author is `Glary Bot
<bot@glary.dev>`, which GitHub does not resolve to a GitHub user. The
CLA action checks the unresolved commit author name after failing to
find a linked GitHub account, so the existing `*[bot]` GitHub App
allowlist does not apply.

## Validation

- Ran `git diff --check`.
2026-06-25 22:48:19 +00:00
AustinMroz
9d20bfd410 Add center dividing line to image compare node (#13132)
| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/37afb473-161c-4350-881e-0ea908e28777"/>
| <img width="360" alt="after"
src="https://github.com/user-attachments/assets/d5acca61-3687-4c15-8029-ef2c88a06944"
/>|
2026-06-25 20:38:59 +00:00
Alexander Brown
c4ed398511 Add assertive profile to review settings (#13120)
## Summary
Rabbit too nice.
2026-06-25 20:28:49 +00:00
AustinMroz
33f0993657 Add long widget values to tooltips (#12864)
If a widget value is long (> 10 characters) and on a known single-line
widget (`number`, `combo`, or `string), then the widget's full value
will be added to the tooltip.

Additionally, margins on combo widgets are slightly tweaked so more of
the text displays before truncation occurs.

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/fefd76e9-6511-4e98-80f6-030a6dc34fb8"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/0cbc100d-066e-4272-afe9-795e56c12353"
/>|
2026-06-25 19:25:02 +00:00
AustinMroz
5b62ec2431 Update CLA allowlist (#13141)
It's `github-actions` without the `[bot]`. This was blocking every PR
that contained updated browser test expectations.

Additionally, the action already included an allow list for every
account ending in `[bot]`. This made half the entries redundant.
2026-06-25 18:30:48 +00:00
jaeone94
604e4d6414 Remove redundant Enter Subgraph action from Errors tab (#13136)
## Summary

Remove the redundant **Enter Subgraph** action from Errors tab node
cards. This button should not be part of the updated Errors tab design;
it remained from the previous implementation and its removal was missed
when the new interaction model was introduced.

## Changes

- **What**: Removed the Errors tab `Enter Subgraph` button from
`ErrorNodeCard`, along with the `enterSubgraph` event plumbing in
`TabErrors`.
- Removed the now-unused `useFocusNode().enterSubgraph()` helper path,
since the Errors tab no longer has a separate subgraph-only action.
- Removed the `ErrorCardData.isSubgraphNode` flag and its population in
`useErrorGroups`, because it only existed to decide whether to show this
button.
- Removed the Storybook story and unit-test expectations that were
specifically tied to the removed button/flag.
- Removed the now-unused English `rightSidePanel.enterSubgraph` i18n
entry. Non-English locale files are intentionally left untouched per the
repo's localization update policy.

## Why

The Errors tab already has a **Locate node on canvas** action. For
errors inside subgraphs, that action navigates into the relevant
subgraph and centers the target node on the canvas. The removed **Enter
Subgraph** action was therefore a weaker duplicate: it entered the
subgraph and fit the view, but did not provide the same direct
target-node positioning.

Keeping both actions made the card UI more crowded and exposed two very
similar navigation paths with overlapping intent. The updated design
should only keep the more useful locate action, so this PR removes the
stale duplicate surface rather than adding another hidden/negative
assertion around it.

## Review Focus

Please verify that this only removes the Errors tab-specific action. The
normal node footer/canvas subgraph navigation behavior remains
untouched.

Validation run locally:

- `pnpm exec vitest run
src/components/rightSidePanel/errors/ErrorNodeCard.test.ts
src/components/rightSidePanel/errors/TabErrors.test.ts
src/components/rightSidePanel/errors/useErrorGroups.test.ts`
- `pnpm typecheck`
- `pnpm lint`
- `pnpm format:check`
- `pnpm knip`


## Screenshot 

Before
<img width="335" height="595" alt="스크린샷 2026-06-25 오후 5 33 37"
src="https://github.com/user-attachments/assets/545f80e9-68bb-45ef-a4da-0a41012269f6"
/>

After
<img width="344" height="591" alt="스크린샷 2026-06-25 오후 5 34 24"
src="https://github.com/user-attachments/assets/7c1f1bf6-c5fd-4a43-9b5c-1392246070a8"
/>
2026-06-25 11:25:49 +00:00
Dante
312a25b63f feat(billing): single billing path — collapse personal/team dispatch to flag-only (B1 / FE-966) (#12953)
## What this does

Collapses the personal-vs-team billing dispatch so it keys ONLY on the
build/flag (`teamWorkspacesEnabled ? 'workspace' : 'legacy'`). Personal
now flows through `useWorkspaceBilling` (`/api/billing/*`), same as team
("personal plan = single-seat workspace"). This converges status /
balance / subscribe / preview / cancel / portal in one change.

Dispatch sites collapsed:
- `useBillingContext.ts` — `type` computed: dropped the
`store.isInPersonalWorkspace` branch → flag-only.
- `useBillingContext.ts` — D3 subscription→store mirror watch: dropped
the `isInPersonalWorkspace` early-return guard so personal also mirrors
into the workspace store.
- `useSubscriptionDialog.ts` — `useWorkspaceVariant` compound predicate
→ flag-only (personal + flag-on now uses the workspace required-dialog
variant).
- `SubscriptionPanel.vue` — already flag-only
(`v-if="teamWorkspacesEnabled"`); no change needed.

## Kept (Risk #6)

- The ~11 raw `workspace.type === 'personal'` checks in
`teamWorkspaceStore.ts` — workspace-TYPE membership logic
(can-delete/leave, fetch-members, switcher), NOT billing dispatch.
Untouched.
- `useLegacyBilling` / `useSubscription` / authStore billing methods
kept intact for the flag-OFF (OSS/Desktop) path.

## Flag-off unchanged

Flag-OFF (OSS/Desktop) still selects `legacy` (`/customers/*`). Verified
by unit test.

## Tests

- `useBillingContext`: flag-ON → personal selects `workspace`; flag-OFF
→ `legacy`; D3 mirror now fires for personal under flag-on.
- `useSubscriptionDialog`: flag-ON → workspace required-dialog variant
for personal; flag-OFF → legacy personal variant.

## Follow-up (deferred, not in this PR)

Post-flip cutover deletion of `useLegacyBilling`-only components:
`PricingTable.vue`, `SubscriptionPanelContentLegacy.vue`,
`TopUpCreditsDialogContentLegacy.vue`, `CurrentUserPopoverLegacy.vue`,
`subscriptionCheckoutUtil.ts`, `useSubscriptionCancellationWatcher.ts`.

- Fixes part of FE-903 (B1)
2026-06-25 06:59:00 +00:00
Dante
b414b1f082 feat(billing): inline Invite-your-team block on team-upgrade success (FE-965 / DES-394) (#12954)
Renders the inline **"Invite your team"** block in the team variant of
FE-934's "You're all set" success card
(`SubscriptionSuccessWorkspace.vue`), so a buyer can invite teammates
right after a team-plan upgrade (DES-394, Figma 3084-18651).

- New shared `InviteMembersForm.vue` (chips / `TagsInput` multi-email
form); seats capped via `useBillingContext.getMaxSeats(tierKey)`; submit
via `workspaceApi.createInvite`.
- Team upgrades only — personal / single-seat plans show the plain
success card; gated on `teamWorkspacesEnabled` + a team plan.
- `workspace_invite_sent` telemetry distinguishing a post-upgrade invite
from a Settings invite; success-card i18n + Storybook story.

**Stacked on FE-934 #12975** (`jaewon/fe-934-team-subscribe-wire`, the
success-card host). The PR base is that branch, so this diff is FE-965's
delta only. Re-using the same form in the Settings invite dialog is out
of scope here (belongs with FE-768 / a follow-up).

## Screenshots

| Team upgrade — invite block | Personal / non-team — no invite block |
|---|---|
| <img
src="https://github.com/user-attachments/assets/be5450fe-2b83-46bd-afbc-00e6d33590b7"
width="420" /> | <img
src="https://github.com/user-attachments/assets/a91909c7-7629-42ef-80b6-45fdb070a0e8"
width="420" /> |

Storybook: `Components/SubscriptionCheckoutSteps` →
`TeamSuccessWithInvite` (with block) / `SuccessAllSet` (without).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-25 05:54:04 +00:00
Dante
f346dd93d2 feat(workspace): promote/demote members via Change role menu (FE-770) (#12782)
Promote / demote workspace members ↔ owners in Settings ▸ Members, per
[DES-222 / Figma
2993-15512](https://www.figma.com/design/CkFTD4c20PyRGpNVAJgpfV/Team-Plan---Workspaces?node-id=2993-15512)
and the [permissions section
3343-22966](https://www.figma.com/design/CkFTD4c20PyRGpNVAJgpfV/Team-Plan---Workspaces?node-id=3343-22966).

- Fixes
[FE-770](https://linear.app/comfyorg/issue/FE-770/promote-demote-workspace-members-owners-settings-members)
- Stacked on #12759 (`jaewon/fe-768-members-invite-ui`)

## Changes

- Per-member row (…) menu → **Change role** submenu (Owner / Member,
current role check-marked) + existing **Remove member**, replacing the
shared PrimeVue `Menu` with the Reka `DropdownMenu`/`DropdownItem`
(submenu opens right of parent, flips on collision; scalable for future
roles).
- **Make [name] an owner?** / **Demote [name] to member?** confirm
dialogs (single `ChangeMemberRoleDialogContent`, copy 1:1 from Figma).
- `workspaceApi.updateMemberRole` → `PATCH
/api/workspace/members/:userId {role}` +
`teamWorkspaceStore.changeMemberRole` (local role map update; Role
column re-sorts).
- **Original-owner guards** (Figma annotations): creator pinned to the
top of the list, no row actions for anyone on that row; own row also has
no actions. Creator inferred as earliest `joined_at` until BE exposes an
explicit flag (tracked as the FE-770 BE blocker — same applies to the
endpoint itself, which does not exist yet; UI is wired to the proposed
contract).
- `DropdownMenu` raised to `z-3000` so the row menu sits above the
Settings modal (the Reka popper wrapper copies the content's computed
z-index; static `z-1700` lost to dialogs in the `@primeuix` modal
sequence). Also drops the always-rendered icon slot in `DropdownItem` so
icon-less items (Change role / Remove member) align flush-left.

## User stories verified

Viewer = an **owner** (promoted, not the workspace creator), so the
creator guard and the self guard are exercised separately.

| # | Click → action → expected |
| --- | --- |
| US1 | Member row (…) → menu shows **Change role ›** + **Remove
member** |
| US2 | Hover **Change role** → Owner / Member submenu, **current role
check-marked** |
| US3 | Click the current role (✓) → no dialog, no PATCH (no-op) |
| US4 | Member row → **Owner** → "Make {name} an owner?" + "They'll have
the same access as you — managing members, billing, and workspace
settings." + Cancel / **Make owner** |
| US5 | **Cancel** (or ✕) → dialog closes, role unchanged, no PATCH |
| US6 | **Make owner** → `PATCH /api/workspace/members/:id
{role:'owner'}` → Role column → Owner, row **re-sorts under the
creator**, "Role updated" toast, the promoted row keeps its (…) menu |
| US7 | Promoted owner row → **Member** → "Demote {name} to member?" +
"They'll lose admin access." → **Demote to member** → Role column back
to Member |
| US8 | **Creator row (earliest joined) has no (…) button** — even for
another owner |
| US9 | **Own (You) row has no (…) button** — even when not the creator
|
| US10 | PATCH 500 → "Failed to update role" toast, **dialog stays
open**, role unchanged |
| US11 | Viewer with `member` role → no row actions anywhere |
| US12 | **Remove member** → existing FE-768 "Remove this member?"
dialog |

## Tests

Each user story is covered by automated tests and confirmed by a manual
CDP pass driving the real cloud app (mocked auth + boot +
workspace/billing API).

| Story | Unit / Component | E2E (Playwright) | CDP (live app) |
| --- | :---: | :---: | :---: |
| US1 row menu shows Change role + Remove member |  |  |  |
| US2 submenu checkmark follows current role |  |  |  |
| US3 picking the current role is a no-op |  |  |  |
| US4 promote dialog copy (Make owner) |  |  |  |
| US5 Cancel leaves role unchanged, no PATCH |  |  |  |
| US6 Make owner → PATCH, re-sort under creator, toast, stays demotable
|  |  |  |
| US7 demote dialog (Demote to member) → role reverts |  |  |  |
| US8 creator row has no (…) menu |  |  |  |
| US9 own (You) row has no (…) menu |  |  |  |
| US10 PATCH 500 → error toast, dialog stays open |  |  |  |
| US11 member-role viewer sees no row actions |  | — | — |
| US12 Remove member → FE-768 remove dialog |  |  |  |

| Layer | File | What it covers | Result |
| --- | --- | --- | --- |
| E2E (`@cloud`) |
`browser_tests/tests/dialogs/memberRoleChange.spec.ts` | 3 tests — guard
rows (US1/US8/US9/US12), promote→re-sort→demote round trip (US3–US7),
failed PATCH (US10). FE-964 boot pattern: `CloudAuthHelper` +
remote-config flag mock + stateful route mocks capturing PATCH args.
Reka submenu driven via `ArrowRight` (synthetic hover doesn't open it).
| 3 / 3 green |
| Component | `ChangeMemberRoleDialogContent.test.ts` | promote/demote
copy, confirm → store + success toast + close, error keeps dialog open,
cancel | green |
| Component | `MembersPanelContent.test.ts` | creator/self rows hide the
menu (US8/US9), member-viewer gating (US11) | green |
| Composable | `useMembersPanel.test.ts` | menu factory
labels/checkmarks/commands, same-role no-op, creator pin in
`sortMembers`, `isOriginalOwner` | green |
| Store | `teamWorkspaceStore.test.ts` | `changeMemberRole`
success/failure, `originalOwnerId` inference | green |
| CDP live | full cloud app on `local.comfy.org` (mocked auth + boot) |
promote→re-sort→demote round trip with PATCH applied to mock state,
guard rows, submenu checkmark, dialog copy, menu/dialog z-index above
Settings, forced PATCH 500 → error toast | verified |

⚠️ Merge-gated on the BE role-change endpoint (no `PATCH
/workspace/members/:userId` in cloud OpenAPI as of 2026-06-10; see
FE-770 BE-blocker comment).

## Screenshots (local dev, workspace/billing API stubbed; vs Figma
2993-15512)

| Members (before) | Change role submenu |
| --- | --- |
| <img alt="members"
src="https://github.com/user-attachments/assets/686fec86-fcb5-4942-a745-50f367022ab0"
/> | <img alt="submenu"
src="https://github.com/user-attachments/assets/d6adeea8-7001-4c8d-91b7-f5bfc47a50d6"
/> |

| Promote dialog | After promote (Jane → Owner, still demotable) |
Demote dialog |
| --- | --- | --- |
| <img alt="promote"
src="https://github.com/user-attachments/assets/af638cde-2fd6-4c37-b203-78801eeb2785"
/> | <img alt="after"
src="https://github.com/user-attachments/assets/f47dc7af-6b1b-422c-8a9a-5ec889b9af11"
/> | <img alt="demote"
src="https://github.com/user-attachments/assets/9a861d04-a23b-4cd4-bc54-1ed3a66c6429"
/> |
2026-06-25 05:04:48 +00:00
Dante
07205c6e43 feat(billing): unify credits into a facade-driven CreditsTile (FE-964) (#12734)
## Summary

### AS IS 
<img width="1340" height="798" alt="Screenshot 2026-06-10 at 12 22 36
AM"
src="https://github.com/user-attachments/assets/61636fa3-e80c-427b-855b-499e1eca67da"
/>

### TO BE

<img width="1301" height="793" alt="Screenshot 2026-06-10 at 12 22 39
AM"
src="https://github.com/user-attachments/assets/62d9f5a6-da92-45df-94e7-cd3c244249f9"
/>

### Empty states ([added to DES-247 on
2026-06-11](https://www.figma.com/design/CkFTD4c20PyRGpNVAJgpfV/Team-Plan---Workspaces?node-id=3349-29750))

| 0 monthly credits | 0 credits |
| --- | --- |
| <img alt="credits-empty-monthly"
src="https://github.com/user-attachments/assets/b3c55d3b-79b0-47b1-9795-c8bf69d5efe2"
/> | <img alt="credits-empty-all"
src="https://github.com/user-attachments/assets/919081d6-64e1-483b-9c04-6b085243ebc1"
/> |

Consolidate the divergent Settings credits surfaces into one
facade-driven **CreditsTile**, implementing the DES-247 redesign so
personal and team modes always render the same balance from
`useBillingContext`.

## Changes

- **What**:
- New `CreditsTile.vue` — total + `remaining`, a stacked
monthly/additional progress bar, colored breakdown rows (`Monthly
(refills …)` / `Additional`), refresh, and a permission-gated *Add
credits* / *Upgrade to add credits* action. Owns the post-checkout
(`focus` / `pending_topup`) balance refresh.
- Extracted the duplicated inline credits card out of
`SubscriptionPanelContentWorkspace.vue` **and**
`SubscriptionPanelContentLegacy.vue` onto the shared tile.
- Replaced `LegacyCreditsPanel.vue` (read `authStore.balance` directly)
with `CreditsPanel.vue` routed through the tile; repointed
`useSettingUI` and deleted the legacy panel.
- `creditsProgress.ts` pure helper for the bar math + numeric credit
getters on `useSubscriptionCredits`.
  - i18n keys for the unified tile labels.
- DES-247 responsive variants via CSS container queries: below ~350px
tile width the `{used} used` label, `remaining` suffix, and breakdown
subtitle drop and the additional-credits value stacks under its label;
below ~230px the monthly summary compacts (`105K left of 200K`).
Additional-credits tooltip copy aligned with the updated design (per
design feedback).
- Empty states (added to DES-247 via Slack on 2026-06-11, low priority):
once the monthly allowance is depleted, an info notice renders under the
total (`Monthly credits are used up. Refills {date}` / `You're now
spending additional credits.`), the monthly bar section dims to 30%
opacity, and an `IN USE` pill marks *Additional credits*; once
everything is depleted the notice switches to `You're out of credits.
Credits refill {date}` and *Add credits* swaps to the `inverted`
(filled-white) Button variant. Gated on a loaded balance so the notice
never flashes while fetching.
- **Dependencies**: Stacked on **#12622 (FE-904 / B2)** for the facade
`tier` / `renewalDate` fields — base this PR against that branch;
retarget to `main` once FE-904 merges.

## Review Focus

- The tile reads everything from the facade (`balance.*Micros` as cents
→ credits, `subscription.tier`/`renewalDate`), so legacy and workspace
modes share one source.
- Monthly allowance still comes from `getTierCredits` (hardcoded tier
nominal). With real data the monthly *remaining* can exceed the nominal
(rolled-over credits), so the bar clamps to a full segment — same
semantics as the prior `{monthly} / {planTotal}` display; the canonical
allowance is a BE-1047 follow-up.
- `LegacyCreditsPanel` deletion: `CreditsPanel.vue` retains the
usage-history table + help links and reads the facade.

## Testing

- Unit/component (36 green): `CreditsTile.test.ts` (render, zero-state,
free-tier, permission gating, add-credits, mount+manual refresh, plus
the empty states: depletion notice copy, `IN USE` badge, `inverted`
button when fully out, no-flash-while-loading guard),
`creditsProgress.test.ts` (clamping/stacking math),
`useSubscriptionCredits.test.ts` (`*_micros`-as-cents), and
`SubscriptionPanel.test.ts` updated for the extracted tile.
- E2E (`@cloud`): `browser_tests/tests/dialogs/creditsTile.spec.ts`
boots the cloud app against mocked Firebase auth + stubbed boot
endpoints (no backend) and asserts the tile's total / progress bar /
monthly+additional breakdown / add-credits in Settings ▸ Workspace ▸
Plan & Credits, then resizes to a narrow viewport and asserts the
responsive variant (labels hidden, compacted `11K left of 21K`). A
second test boots with a drained monthly balance (0-monthly notice + `IN
USE` badge), then re-mocks a fully drained balance and refreshes the
tile in place to assert the out-of-credits state. Both pass locally
against a cloud dev server; runs in the `cloud` CI project. Drives a raw
page because the shared `comfyPage` fixture expects the OSS devtools
backend.
- Screenshot-verified the tile at the three DES-247 reference widths
(448 / 235 / 204px) against the Figma Responsiveness section — 1:1.
- Verified live in the running app (Settings ▸ Workspace ▸ Plan &
Credits) against the authenticated backend — renders 1:1 with DES-247.
The empty-state screenshots above were captured the same way
(authenticated app, real Pro subscription, balance endpoint stubbed to
the depleted values via CDP).
- `pnpm typecheck` / `typecheck:browser` / `lint` / `knip` green.

Implements FE-964 (DES-247).

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-25 04:44:59 +00:00
Dante
c39f378ba4 feat(billing): team-plan subscribe + checkout/confirm screen redesign (FE-934) (#12975)
## Summary

Two related streams of FE-934 checkout/billing work on this branch:



https://github.com/user-attachments/assets/af629def-543e-4bcd-894d-b35aa032fe0a



1. **Team-plan subscribe** wired to the BE-1254 credit-stop contract
(replaces the "coming soon" toast stub).
2. **Checkout / confirm screen redesign** aligned to the updated DES
mockup — yearly pricing display, dialog navigation, and the plan-change
confirm.

<img width="1078" height="427" alt="team subscribe"
src="https://github.com/user-attachments/assets/2b5f7192-3c91-4e2d-b495-832ca5a26657"
/>

## Changes

### Team-plan subscribe (credit-stop contract)

- Team subscribe sends `{ plan_slug: team_per_credit_monthly |
team_per_credit_annual, team_credit_stop_id, billing_cycle }` to `POST
/api/billing/subscribe` and handles the response like the personal path
(`subscribed` → success, `needs_payment_method` → payment URL,
`pending_payment` → poll). Slider stops come from `GET
/api/billing/plans → team_credit_stops` via `mapApiTeamCreditStops`,
falling back to the hardcoded DES-197 stops so OSS / pre-deploy still
render. `preview-subscribe` is unchanged — the team confirm step is
display-only.
- **Internal API change**: `workspaceApi.subscribe(planSlug, returnUrl?,
cancelUrl?)` → `subscribe(planSlug, options?: SubscribeOptions)`; the
billing facade (`useBillingContext` / `useWorkspaceBilling` /
`useLegacyBilling`) and callers were updated to match.

### Checkout / confirm screen redesign (DES mockup)

- **Yearly confirm**: headline is now the ÷12 monthly-equivalent with a
`{total} Billed yearly` (yearly) / `Billed monthly` (monthly) subtitle;
credits show `Each year credits refill to` (×12) for yearly; `Starting
today` → `Starts today`. The team confirm now receives the active
billing cycle (the subtitle was missing because it wasn't passed), and
the redundant header credits/month line was dropped.
- **Navigation**: the pricing table stays mounted (`v-show`, not `v-if`)
so the plan / billing-cycle / credit-stop selection survives a round
trip to the confirm step and back; **Backspace** mirrors the back arrow
(ignored while an input/textarea/contenteditable is focused).
- **Plan-change confirm**: rewritten from the 2-column current→new
comparison to the **single-plan layout**, branching on
`previewData.is_immediate` — immediate upgrades show prorated line items
(`new_plan.price_cents − cost_today_cents` = credit) + upfront (yearly)
/ monthly credit refill + a prorated total ("Confirm upgrade");
scheduled downgrades show `Starts {date}`, $0 due today, and an "After
that" block ("Confirm change").
- **Storybook**: `SubscriptionCheckoutSteps` stories for each new-sub /
upgrade / downgrade variation (props-driven, no API).

## Review Focus

- **Merge gate (team subscribe)** —  **resolved**: BE-1254 is now
merged in cloud `main`. The live `GET /api/billing/plans` returns
`team_credit_stops` (`TeamCreditStop` struct + validation in
`common/billing/catalog/catalog.go`, served for every workspace,
asserted by the `billing_credit_stops_contract` smoke test). The gate is
lifted.
- **Fallback safety**: with the contract live, the hardcoded DES-197
stops are now purely the OSS / pre-deploy fallback — they render only
when the API doesn't supply `team_credit_stops`. In that window a real
subscribe is still impossible (no stop `id`) and surfaces a
`teamPlan.unavailable` toast. Open question retained: hide/disable the
team CTA in that window instead of toasting?
- **Plan-change scope**: the single-plan confirm redesign covers
**personal** changes (the real `previewSubscribe` path). **Team** plan
changes route through the display-only team confirm and aren't wired to
`previewSubscribe` (BE left `PreviewSubscribeRequest` as `plan_slug`
only) — team-change proration is a follow-up.
- **Dead locale keys**: the old 2-column transition keys
(`everyMonthStarting`, `youllBeCharged`, `proratedRefund`,
`proratedCharge`, `creditsRefillTo`, `switchToPlan`, `starting`, `ends`,
`confirmPlanChange`) are now unused — can be removed in a follow-up
cleanup.
- **Out of scope**: the success "Your change is scheduled" variant from
the mockup.
- Based on `fe-934-unified-pricing-table`; base + `main` merged in to
resolve conflicts (PR is now mergeable).

## Verification

- Confirm / transition component tests green
(`SubscriptionAddPaymentPreviewWorkspace`,
`SubscriptionTransitionPreviewWorkspace`); oxlint / oxfmt clean locally;
full typecheck runs in CI.
- Each variation viewable in Storybook →
`Components/SubscriptionCheckoutSteps`.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-25 04:27:44 +00:00
Dante
00c7eaef79 feat: route cloud auth through the single Cloud JWT under unified_cloud_auth (FE-950) - step 3 (#12708)
## Summary

Behind `unified_cloud_auth` (default OFF), flip the two token accessors
so every cloud request rides the single Cloud JWT minted in PR2. This is
the consumer-flip phase of FE-950: PR2 built the dormant `unifiedToken`
slot; this PR makes consumers read it — and surfaces the
permanent-auth-failure path that the flip turns from graceful
degradation into a hard stop.

Stacked on #12704 (PR2), now merged; base is `main`.

## Changes

- **What**:
- `getAuthHeader()` — flag ON returns `{ Authorization: Bearer
<unifiedToken> }` (or `null` if unminted), with **no** Firebase/API-key
fallback. Flag OFF keeps the exact workspace → Firebase → API-key
cascade.
- `getAuthToken()` — flag ON returns the unified Cloud JWT (or
`undefined`); flag OFF keeps workspace → Firebase.
- Both accessors are the single seam every cloud consumer already routes
through, so the flip propagates automatically with **no edits** to
`fetchApi` (`scripts/api.ts`), `/customers/*` (authStore),
`workspaceApi`, the WebSocket (`api.ts:568`), or backend-node auth
(`app.ts:1593`).
- **Surface permanent auth failures** (answers @pythongosssss's review
on PR2). Under the flag there is no Firebase fallback, so a silent
`clearUnifiedContext()` wipe would strand every cloud request until
manual re-login — unlike the legacy path, which degrades to the Firebase
token. `refreshUnified()` and `mintAtLogin()` now emit a user-facing
error toast (keyed by error code off the existing `workspaceAuth.errors`
i18n) on the permanent codes (`ACCESS_DENIED` / `WORKSPACE_NOT_FOUND` /
`INVALID_FIREBASE_TOKEN` / `NOT_AUTHENTICATED`). `mintAtLogin()` now
resolves `false` on a permanent failure instead of rejecting an
unhandled `void`ed promise. Transient failures stay silent (proactive
refresh still retries). Also trims the verbose unified-lifecycle
comments flagged in review.
- **Breaking**: None. Flag OFF is byte-for-byte the current cascade.

## Review Focus

- **Single token, no fallback under the flag.** Tests assert
`getAuthHeader`/`getAuthToken` return only the unified token and never
call `getIdToken` or the API-key store; they return `null`/`undefined`
(not a fallback) when the token is unminted.
- **Surfacing, not recovery.** This PR makes the terminal state
*visible* (toast); the existing router auth-guard still redirects to
login on the next navigation. Active recovery (automatic re-mint on 401)
stays in the deferred safety-net PR so the toast is never a dead-end
"please re-login" with the fix one PR away.
- **Flag-OFF parity.** The full existing cascade suite runs with
`unifiedCloudAuthEnabled = false` (the `beforeEach` default) and stays
green.

## Deferred (intentional)

- **`acceptInvite` is left unchanged — still Firebase-authed.** It is
the one cloud call that intentionally keeps the raw Firebase token,
because the invite is accepted *before* the user is a member of the
target workspace. Promoting it to the unified Cloud JWT first needs a
quick check that `POST /invites/:token/accept` accepts a personal-scoped
Cloud JWT for a not-yet-member; deferred until that is verified.
`getFirebaseAuthHeader` / `getFirebaseAuthHeaderOrThrow` stay defined
(their removal belongs to the later cleanup ticket, FE-951). No
`workspaceApi.ts` change in this PR.
- **The reactive 401 re-mint + retry safety net is a follow-up.** A
clean place to intercept a `401` and re-mint once does not exist yet:
cloud requests use raw `fetch` (`/customers/*`, `/auth/token`) plus
several independent axios clients (`workspaceApi`,
`customerEventsService`, registry, manager), with no shared response
interceptor. PR2's `remintUnifiedOnce()` primitive is ready, and the
proactive buffer-based refresh (`refreshUnified`) already covers the
common token-*expiry* case, so this cross-cutting safety net (plus
deciding whether the surfacing toast escalates to a guided re-login CTA
once remint exists) lands in its own focused PR before any production
rollout. Note this is orthogonal to the surfacing above: proactive
refresh prevents expiry; it cannot prevent *revocation*, which is
exactly what triggers the now-surfaced permanent-error path.

## Tests

- Extended `authTokenPriority.test.ts`: flag-ON `getAuthHeader` returns
only the unified JWT (Firebase + API-key + workspace untouched) and
`null` when unminted; flag-ON `getAuthToken` returns the unified JWT
(not Firebase) and `undefined` when unminted. Existing cascade tests
prove flag-OFF parity.
- Added to `useWorkspaceAuth.test.ts` (red-green + regression lock): a
permanent refresh error toasts the **correct i18n key for each of the
four permanent codes** (`it.for` over 403/404/401 + a
lost-Firebase-token `NOT_AUTHENTICATED` case) and clears the slot; a
permanent login-mint error toasts and resolves `false`. Negative guards
prove the surfacing is **error-only and flag-scoped**: a transient (5xx)
refresh does **not** toast and keeps the slot, a **successful** re-mint
does not toast, and the unified lifecycle **never toasts when the flag
is OFF** (even against a rejecting backend).

## Red-Green Verification

| Commit | CI | Purpose |
|--------|-----|---------|
| `test: cover permanent unified-auth error surfacing` | 🔴
Red
([run](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27455949404))
| Proves the tests catch the silent-failure gap |
| `fix: surface permanent unified-auth errors instead of failing
silently` | 🟢 Green
([run](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27456200098))
| Proves the surfacing resolves it |

Part of FE-950 (single Cloud-JWT provider at login, Phase 1).
2026-06-25 03:42:57 +00:00
Dante
09d59616f5 feat(billing): deep link to open the pricing table (FE-1104) (#13001)
## Summary

Adds an in-app deep link that opens the pricing table directly, for
driving pilot users straight to subscribe (request from nav/Alex).
Resolves [FE-1104](https://linear.app/comfyorg/issue/FE-1104).

- `/?pricing=1` — on app load, open the pricing table.
- `/?pricing=team` / `/?pricing=personal` — open it on the Team /
Personal plan tab (via the existing `UnifiedPricingTable`
`initialPlanMode`).
- Gated to the **original owner** via
`useWorkspaceUI().permissions.canManageSubscriptionLifecycle` (personal
user, or a team workspace's original owner). A member or a promoted
owner is a **silent no-op**: the app loads normally, the param is
stripped, no 404 / error / toast.
- Off-cloud (OSS): the loader isn't instantiated, so the param is
ignored.
- Survives the login redirect via the preserved-query system, same as
`?invite` / `?create_workspace`.

## How

Mirrors the established URL-loader pattern (`useInviteUrlLoader` /
`useCreateWorkspaceUrlLoader`):

- `preservedQueryNamespaces.ts` / `router.ts` — register the `pricing`
namespace + tracker key.
- New `usePricingTableUrlLoader.ts` — hydrate preserved query, read
`pricing`, strip the param + `clearPreservedQuery` in a single replace
before any await, then `await fetchMembers()` (resolves the
original-owner gate; no-ops for personal) and open the table only when
the gate allows.
- `GraphCanvas.vue` — call the loader in `onMounted` after the
create-workspace loader (cloud only; not gated on the team-workspaces
flag so it also drives personal/legacy users).
- `useSubscriptionDialog.ts` — new `'deep_link'` value on
`SubscriptionDialogReason`.

## Telemetry

Eligible opens emit the existing `subscription_required_modal_opened`
PostHog event with the new `reason: 'deep_link'`. Ineligible-click
bounce rate is derivable from the autocaptured pageview URL
(`?pricing=…`), so no new event plumbing.

## Stacking / dependencies

This feature needs two sibling stacks off `main`:

- **FE-934** (`#12666`, base of this PR) — the `UnifiedPricingTable` +
`showPricingTable({ planMode })`.
- **FE-770** (`#12829`) — the `canManageSubscriptionLifecycle` gate.
**Merged into this branch**, so the diff against the FE-934 base
includes FE-770's changes until it lands. Review the single
`feat(billing): deep link…` commit. Once both land on `main`, rebase
onto `main` and the diff collapses to just this feature.

Do not merge before FE-770 and FE-934. Post-Billing-V1 follow-up.
End-state: swap the FE original-owner heuristic for the BE
workspace-level `is_original_owner` flag when it lands (removes the
members-fetch).

## Tests

- Unit (`usePricingTableUrlLoader.test.ts`, 12 cases): opens for an
original owner; `team`/`personal` tab preselect; silent no-op +
param-strip for a member/promoted owner; proves the gate is read only
after `fetchMembers` resolves; preserved-query restore;
empty/non-string/absent/unrecognized param; members-fetch failure
strips+clears without opening.
- E2E (`browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts`,
`@cloud`, 4 cases, verified locally): personal owner opens + URL
stripped; `?pricing=team` lands on the active Team tab; team original
owner opens (real `is_original_owner` + email gate); team member is a
silent no-op + URL stripped.
- Typecheck + related unit suites (`useSubscriptionDialog`,
`useWorkspaceUI`, `teamWorkspaceStore`) green.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-06-25 02:30:07 +00:00
Dante
e8ea16463f feat(workspace): redesign Members invite UI to DES-186 (FE-768) (#12759)
## Summary

Redesigns the Settings ▸ Workspaces Members invite flow to DES-186:
email-chips invite dialog, Resend/Cancel pending-invite actions, and
explicit team-plan gating.

## Changes

- **What**:
- `InviteMemberDialogContent`: single-email link-generation flow →
comma/Enter/paste-separated email chips (`TagsInput`); batch
`createInvite` via `Promise.allSettled` — on partial failure, failed
emails stay as chips with an error toast; success shows an Invited
state.
- `PendingInvitesList`: inline Copy-link/Revoke buttons → `⋯` overflow
menu with **Resend invite** / **Cancel invite**. New
`teamWorkspaceStore.resendInvite` issues the fresh invite *before*
revoking the old one, so a failed resend never destroys the original.
- FE-built invite links removed
(`getInviteLink`/`createInviteLink`/`copyInviteLink`,
`PendingInvite.token`) — invite delivery is BE email per DES-186.
- New `useTeamPlan` composable: explicit `isOnTeamPlan` (seat-based
`maxSeats > 1` proxy until BE-1254 exposes the plan signal) replaces
`isSingleSeatPlan` branching in
`WorkspacePanelContent`/`useMembersPanel`.
- Personal/no-team-plan state per Figma 2993-14604: "To add teammates,
upgrade your plan." banner + **Upgrade to Team**; "Need more members?
Contact us" footer; upsell dialog copy → Team-plan framing; members sort
by Role column (was Join date).
- **Header layout per design (2nd commit)**: `[Search][Invite +][⋯
workspace menu]` moved from the dialog tab row into the members card
header row (the "N of M members" row) — the tab row right side is empty
in every DES-186 frame. Workspace menu extracted to
`WorkspaceMenuButton.vue`.
- **Single-member gating per design annotations**: when the owner is
alone — search hidden ("Show if more than 1 members"), Active/Pending
segmented tabs hidden, role column hidden, and the Members tab label
drops its count ("Remove '(0)' as well if it's just 1 member"). Tabs
reappear if pending invites exist so they stay manageable.
- **Breaking**: `teamWorkspaceStore` invite-link API removed (no
external consumers found in-repo).

## Review Focus

- Resend semantics (create-first, then revoke) and pending-invites state
update in `teamWorkspaceStore.resendInvite`.
- Invite-link removal assumes BE sends invite emails (DES-186 annotation
"Resends the invite email"). If BE email delivery is not yet live, this
PR should wait on that confirmation.
- Gating matrix: team-active owner → enabled; team at seat cap →
disabled+tooltip; non-team plan → upsell dialog; personal workspace →
disabled.
- Workspace `⋯` menu (Edit/Delete/Leave) now lives only on the Members
tab card header, matching DES-186 — the Plan & Credits tab no longer
exposes it (design shows the plan-card `⋯` there instead, shipped via
FE-964/FE-768b).

## Screenshots

Captured on `local.comfy.org` dev (cloud-prod backend, authenticated
session; team-plan states use client-side XHR-level API stubs for
subscription/members/invites since the test workspaces are
unsubscribed).

| Flow | Before (main) | After (this PR) |
|---|---|---|
| Members list — team plan | <img width="400" alt="before members"
src="https://github.com/user-attachments/assets/be214b95-4783-47ff-9539-59c8a33b5eb9"
/> | <img width="400" alt="after: Search/Invite/menu in the card header
row, Role column, design demo data"
src="https://github.com/user-attachments/assets/841c89d5-2a29-4eed-9c72-4a5ee8bee9f4"
/> |
| Pending invites — row actions | <img width="400" alt="before pending:
inline copy-link/revoke icons"
src="https://github.com/user-attachments/assets/1703850e-86bc-4735-81b3-7530c01ad46f"
/> | <img width="400" alt="after pending: overflow menu with
Resend/Cancel invite"
src="https://github.com/user-attachments/assets/9b1bbe03-d82b-4cf6-86f2-e01d7b898ae7"
/> |
| Team workspace with 1 member | (same chrome as members list: search +
tabs always shown) | <img width="400" alt="after: no search/tabs/role,
tab label without count, Invite + menu in card row"
src="https://github.com/user-attachments/assets/22c7f68a-eb0f-49a9-a203-516cd9b7e02d"
/> |
| Invite dialog | <img width="400" alt="before: single email, Create
link"
src="https://github.com/user-attachments/assets/c19d8bbb-feb5-4fe4-8541-6d52e6ab6600"
/> | <img width="400" alt="after: comma-separated email chips"
src="https://github.com/user-attachments/assets/101fdc7b-d6e0-4f7d-8966-894bbf16b4aa"
/> |
| Invite dialog — submit result | (copies a link) | <img width="400"
alt="after: Invited success state"
src="https://github.com/user-attachments/assets/f539a88c-0250-434c-bf4a-6ce714b30398"
/> |
| Upsell — invite without team plan | <img width="400" alt="before:
subscription required, Creator plan copy"
src="https://github.com/user-attachments/assets/bb2cb9fd-f298-4cb0-b39a-6d59061dcea1"
/> | <img width="400" alt="after: Team plan required, Upgrade to Team"
src="https://github.com/user-attachments/assets/45170ed5-63bd-469a-af12-197a8b7e09ee"
/> |
| Personal workspace — Members tab | (create-workspace hint text) | <img
width="400" alt="after: upgrade banner + disabled Invite"
src="https://github.com/user-attachments/assets/a0ae664b-2d20-4d87-900d-7a36872ecde3"
/> |

Linear:
[FE-768](https://linear.app/comfyorg/issue/FE-768/updates-to-workspaces-tab-of-settings)
(Members half; Plan & Credits panel ships separately stacked on FE-964)
2026-06-25 02:29:46 +00:00
Matt Miller
3cead52236 Merge branch 'main' into mattmillerai/fix-customer-create-409 2026-06-22 13:10:06 -07:00
GitHub Action
bad3bc20ec [automated] Apply ESLint and Oxfmt fixes 2026-06-19 02:50:57 +00:00
mattmiller
1fa46b1586 fix: guard customerCreated and customerRecovery against session races
Two concurrent-auth bugs in recoverMissingCustomer:

1. Stale rejection race: the .catch cleared customerRecovery
   unconditionally, so a failed promise from session N could null
   out the in-flight promise for session N+1. Fix: capture the
   promise in a local ref and only null customerRecovery when it
   still points to this attempt.

2. createCustomer bleed: customerCreated.value = true fired after
   await even if the auth state had already reset to a different
   (or absent) session. Fix: snapshot the user uid before the
   fetch and skip the assignment if the session changed mid-flight.

Both races are covered by new Vitest tests.
2026-06-18 19:46:52 -07:00
Matt Miller
371afa1235 fix: harden customer 409 recovery (review findings)
- Reset customer provisioning state on auth state changes: without this,
  a second account in the same browser session was short-circuited by
  the previous account's memoized recovery and stayed stuck on 409s.
- Gate recovery on the missing-customer 409 body ("Failed to find
  customer") so business-level 409s from billing endpoints don't
  trigger spurious customer creation or a blind retry of a payment
  request. Also enforces the /customers/* contract via a URL path
  guard now that the wrapper is exported.
- Route addCredits' pre-flight customer creation through the shared
  recovery memo so it can't race a concurrent 409-triggered recovery
  into duplicate POST /customers.
- Guard the retry fetch: a network failure now returns the original
  409 response instead of escaping as a TypeError, and recovery
  failures are logged for observability.

Deliberately unchanged: Authorization headers are not re-resolved on
retry (the auth header source is caller-dependent — workspace vs user
scoped — and re-resolving risks swapping scopes; a stale token fails
safe into the caller's normal error path), and the shared recovery
promise has no timeout (bounded by the platform fetch timeout).

Adds tests: business-409 passthrough, non-customer-endpoint
passthrough, re-provisioning after auth state change, network-failure
retry fallback, concurrent credit pre-flight dedupe.
2026-06-17 11:49:30 -07:00
Matt Miller
57ca24d9f6 fix(cloud): self-heal missing customer record on 409 from /customers/* endpoints
Customer creation runs after the Firebase session is established during
sign-in/sign-up, so an interruption (navigation, closed window, network
failure) can leave a permanently signed-in user without a customer
record. Sessions restored from persisted credentials never re-run the
sign-in flow, so every /customers/* request (subscription status,
balance, checkout, billing portal) fails with 409 and nothing ever
retries the creation - affected users cannot subscribe at all.

Add a fetch wrapper in authStore that, on a 409 from a /customers/*
endpoint, provisions the customer record once (deduplicated across
concurrent callers, memoized per session on success) and retries the
original request a single time. If provisioning fails the original 409
is returned so callers keep their existing error handling. Wired into
all fetch-based /customers/* call sites.
2026-06-17 11:49:29 -07:00
Matt Miller
25cfca7bca Merge branch 'main' into ci/cursor-review-workflow 2026-06-17 11:26:20 -07:00
Matt Miller
8d23daa33d fix: resolve review feedback 2026-06-17 11:25:19 -07:00
GitHub Action
880582ab5d [automated] Apply ESLint and Oxfmt fixes 2026-06-17 18:21:48 +00:00
Matt Miller
e498c4ae0d ci: bump cursor-review SHA to github-workflows#9, drop judge_model override 2026-06-17 11:17:58 -07:00
Matt Miller
4cadbb8af9 fix: resolve review feedback 2026-06-15 15:42:26 -07:00
Matt Miller
cc048464fa ci: add team-gated Cursor review (thin caller for github-workflows)
Calls the reusable Comfy-Org/github-workflows cursor-review.yml (single source of truth for panel, judge, prompts, scripts) instead of a standalone copy. Label-gated to the team; secret-bearing jobs skip fork PRs. Judge overridden to Opus 4.8.
2026-06-15 14:36:26 -07:00
7 changed files with 526 additions and 27 deletions

View File

@@ -115,6 +115,8 @@ vi.mock('@/stores/authStore', () => ({
useAuthStore: () =>
reactive({
getAuthHeader: mockGetAuthHeader,
fetchWithCustomerRecovery: (input: string, init?: RequestInit) =>
fetch(input, init),
userId: computed(() => mockUserId.value)
}),
AuthStoreError: class extends Error {}

View File

@@ -143,6 +143,8 @@ vi.mock('@/services/dialogService', () => ({
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
getAuthHeader: mockGetAuthHeader,
fetchWithCustomerRecovery: (input: string, init?: RequestInit) =>
fetch(input, init),
get isInitialized() {
return mockAuthStoreInitialized.value
},

View File

@@ -9,10 +9,8 @@ import {
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { fetchWithUnifiedRemint } from '@/platform/auth/unified/remintRetry'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
@@ -55,8 +53,7 @@ function useSubscriptionInternal() {
const { showSubscriptionRequiredDialog } = useDialogService()
const authStore = useAuthStore()
const { getAuthHeader } = authStore
const { flags } = useFeatureFlags()
const { getAuthHeader, fetchWithCustomerRecovery } = authStore
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { isLoggedIn } = useCurrentUser()
@@ -320,12 +317,11 @@ function useSubscriptionInternal() {
async function fetchSubscriptionStatus(): Promise<CloudSubscriptionStatusResponse | null> {
const headers = await buildAuthHeaders()
const response = await fetchWithUnifiedRemint(
const response = await fetchWithCustomerRecovery(
buildApiUrl('/customers/cloud-subscription-status'),
{
headers
},
isCloud && flags.unifiedCloudAuthEnabled
}
)
if (!response.ok) {
@@ -411,14 +407,13 @@ function useSubscriptionInternal() {
const headers = await buildAuthHeaders()
const checkoutAttribution = await getCheckoutAttributionForCloud()
const response = await fetchWithUnifiedRemint(
const response = await fetchWithCustomerRecovery(
buildApiUrl('/customers/cloud-subscription-checkout'),
{
method: 'POST',
headers,
body: JSON.stringify(checkoutAttribution)
},
isCloud && flags.unifiedCloudAuthEnabled
}
)
if (!response.ok) {

View File

@@ -71,6 +71,8 @@ vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() =>
reactive({
getAuthHeader: mockGetAuthHeader,
fetchWithCustomerRecovery: (input: string, init?: RequestInit) =>
fetch(input, init),
userId: computed(() => mockUserId.value)
})
),

View File

@@ -1,9 +1,7 @@
import { storeToRefs } from 'pinia'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { fetchWithUnifiedRemint } from '@/platform/auth/unified/remintRetry'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import {
createPendingSubscriptionCheckoutAttempt,
@@ -86,14 +84,13 @@ export async function performSubscriptionCheckout(
}
const checkoutPayload = { ...checkoutAttribution }
const response = await fetchWithUnifiedRemint(
const response = await authStore.fetchWithCustomerRecovery(
`${getComfyApiBaseUrl()}/customers/cloud-subscription-checkout/${checkoutTier}`,
{
method: 'POST',
headers: { ...authHeader, 'Content-Type': 'application/json' },
body: JSON.stringify(checkoutPayload)
},
isCloud && useFeatureFlags().flags.unifiedCloudAuthEnabled
}
)
if (!response.ok) {

View File

@@ -1063,4 +1063,373 @@ describe('useAuthStore', () => {
expect((error as AuthStoreError).status).toBe(422)
})
})
describe('fetchWithCustomerRecovery', () => {
function make409(message: string) {
const body = { message }
return {
ok: false,
status: 409,
statusText: 'Conflict',
json: () => Promise.resolve(body),
clone: () => ({ json: () => Promise.resolve(body) })
}
}
function makeConflictResponse() {
return make409('Failed to find customer')
}
function countCustomerPosts() {
return mockFetch.mock.calls.filter(
([url, init]) =>
typeof url === 'string' &&
url.endsWith('/customers') &&
(init as RequestInit | undefined)?.method === 'POST'
).length
}
it('should provision the customer and retry once when a /customers/* call returns 409', async () => {
let balanceCalls = 0
mockFetch.mockImplementation((url: string, init?: RequestInit) => {
if (url.endsWith('/customers') && init?.method === 'POST') {
return Promise.resolve(mockCreateCustomerResponse)
}
if (url.endsWith('/customers/balance')) {
balanceCalls++
return Promise.resolve(
balanceCalls === 1
? makeConflictResponse()
: mockFetchBalanceResponse
)
}
return Promise.reject(new Error('Unexpected API call'))
})
const result = await store.fetchBalance()
expect(result).toEqual({ balance: 0 })
expect(balanceCalls).toBe(2)
expect(countCustomerPosts()).toBe(1)
})
it('should deduplicate concurrent recovery attempts into a single customer creation', async () => {
const seenUrls = new Set<string>()
mockFetch.mockImplementation((url: string, init?: RequestInit) => {
if (url.endsWith('/customers') && init?.method === 'POST') {
return Promise.resolve(mockCreateCustomerResponse)
}
if (!seenUrls.has(url)) {
seenUrls.add(url)
return Promise.resolve(makeConflictResponse())
}
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) })
})
const [first, second] = await Promise.all([
store.fetchWithCustomerRecovery('https://api.test/customers/balance'),
store.fetchWithCustomerRecovery(
'https://api.test/customers/cloud-subscription-status'
)
])
expect(first.ok).toBe(true)
expect(second.ok).toBe(true)
expect(countCustomerPosts()).toBe(1)
})
it('should not provision the customer again after a successful recovery', async () => {
mockFetch.mockImplementation((url: string, init?: RequestInit) => {
if (url.endsWith('/customers') && init?.method === 'POST') {
return Promise.resolve(mockCreateCustomerResponse)
}
// Endpoint keeps conflicting even after recovery succeeds
return Promise.resolve(makeConflictResponse())
})
const first = await store.fetchWithCustomerRecovery(
'https://api.test/customers/balance'
)
const second = await store.fetchWithCustomerRecovery(
'https://api.test/customers/balance'
)
expect(first.status).toBe(409)
expect(second.status).toBe(409)
expect(countCustomerPosts()).toBe(1)
})
it('should return the original 409 response when customer provisioning fails', async () => {
mockFetch.mockImplementation((url: string, init?: RequestInit) => {
if (url.endsWith('/customers') && init?.method === 'POST') {
return Promise.resolve({
ok: false,
status: 500,
statusText: 'Internal Server Error'
})
}
return Promise.resolve(makeConflictResponse())
})
const response = await store.fetchWithCustomerRecovery(
'https://api.test/customers/balance'
)
expect(response.status).toBe(409)
expect(countCustomerPosts()).toBe(1)
})
it('should pass through non-409 responses without provisioning', async () => {
mockFetch.mockImplementation(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve({}) })
)
const response = await store.fetchWithCustomerRecovery(
'https://api.test/customers/balance'
)
expect(response.ok).toBe(true)
expect(countCustomerPosts()).toBe(0)
})
it('should not provision for a 409 that is not a missing-customer conflict', async () => {
mockFetch.mockImplementation(() =>
Promise.resolve(make409('Subscription already active'))
)
const response = await store.fetchWithCustomerRecovery(
'https://api.test/customers/cloud-subscription-checkout/standard',
{ method: 'POST' }
)
expect(response.status).toBe(409)
expect(countCustomerPosts()).toBe(0)
})
it('should not provision for a 409 from a non-customer endpoint', async () => {
mockFetch.mockImplementation(() =>
Promise.resolve(makeConflictResponse())
)
const response = await store.fetchWithCustomerRecovery(
'https://api.test/workflows'
)
expect(response.status).toBe(409)
expect(countCustomerPosts()).toBe(0)
})
it('should not provision when /customers/ is not the root path segment', async () => {
mockFetch.mockImplementation(() =>
Promise.resolve(makeConflictResponse())
)
const response = await store.fetchWithCustomerRecovery(
'https://api.test/foo/customers/bar'
)
expect(response.status).toBe(409)
expect(countCustomerPosts()).toBe(0)
})
it('should re-provision after the auth state changes to a different session', async () => {
mockFetch.mockImplementation((url: string, init?: RequestInit) => {
if (url.endsWith('/customers') && init?.method === 'POST') {
return Promise.resolve(mockCreateCustomerResponse)
}
return Promise.resolve(makeConflictResponse())
})
await store.fetchWithCustomerRecovery(
'https://api.test/customers/balance'
)
expect(countCustomerPosts()).toBe(1)
// Sign out, then a different account signs in: the memoized recovery
// from the previous account must not short-circuit the new one.
authStateCallback(null)
authStateCallback(mockUser)
await store.fetchWithCustomerRecovery(
'https://api.test/customers/balance'
)
expect(countCustomerPosts()).toBe(2)
})
it('re-provisions when the active uid changes without an auth-state reset', async () => {
mockFetch.mockImplementation((url: string, init?: RequestInit) => {
if (url.endsWith('/customers') && init?.method === 'POST') {
return Promise.resolve(mockCreateCustomerResponse)
}
return Promise.resolve(makeConflictResponse())
})
await store.fetchWithCustomerRecovery(
'https://api.test/customers/balance'
)
expect(countCustomerPosts()).toBe(1)
// A uid change that did not pass through onAuthStateChanged (which would
// otherwise clear the memo) must still start a fresh recovery rather than
// reuse the previous account's settled one.
store.currentUser = {
...mockUser,
uid: 'different-uid'
} as Partial<User> as User
await store.fetchWithCustomerRecovery(
'https://api.test/customers/balance'
)
expect(countCustomerPosts()).toBe(2)
})
it('should return the original 409 when the retry fails at the network level', async () => {
let balanceCalls = 0
mockFetch.mockImplementation((url: string, init?: RequestInit) => {
if (url.endsWith('/customers') && init?.method === 'POST') {
return Promise.resolve(mockCreateCustomerResponse)
}
balanceCalls++
return balanceCalls === 1
? Promise.resolve(makeConflictResponse())
: Promise.reject(new TypeError('network down'))
})
const response = await store.fetchWithCustomerRecovery(
'https://api.test/customers/balance'
)
expect(response.status).toBe(409)
expect(countCustomerPosts()).toBe(1)
})
it('should share one customer creation between concurrent credit pre-flights', async () => {
mockFetch.mockImplementation((url: string, init?: RequestInit) => {
if (url.endsWith('/customers') && init?.method === 'POST') {
return Promise.resolve(mockCreateCustomerResponse)
}
if (url.endsWith('/customers/credit')) {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({ checkout_url: 'https://stripe.test/checkout' })
})
}
return Promise.reject(new Error('Unexpected API call'))
})
await Promise.all([
store.initiateCreditPurchase({
amount_micros: 5_000_000,
currency: 'usd'
}),
store.initiateCreditPurchase({
amount_micros: 5_000_000,
currency: 'usd'
})
])
expect(countCustomerPosts()).toBe(1)
})
it('stale rejection from previous session does not null out a new in-flight recovery', async () => {
let rejectSession1Create!: (reason: unknown) => void
let resolveSession2Create!: (value: Response) => void
let postCount = 0
const session1CreateP = new Promise<Response>((_, reject) => {
rejectSession1Create = reject
})
const session2CreateP = new Promise<Response>((resolve) => {
resolveSession2Create = resolve
})
mockFetch.mockImplementation((url: string, init?: RequestInit) => {
if (url.endsWith('/customers') && init?.method === 'POST') {
postCount++
return postCount === 1 ? session1CreateP : session2CreateP
}
return Promise.resolve(makeConflictResponse())
})
// Session 1: trigger a recovery whose POST will hang
const session1Done = store
.fetchWithCustomerRecovery('https://api.test/customers/balance')
.then(() => 'session1')
.catch(() => 'session1-failed')
// Drain microtasks so session1's POST is registered
await new Promise<void>((resolve) => setTimeout(resolve, 0))
// Auth resets; new session starts
authStateCallback(null)
authStateCallback(mockUser)
// Session 2 recovery — should create a new independent in-flight promise
const session2Done = store
.fetchWithCustomerRecovery('https://api.test/customers/balance')
.then(() => 'session2')
.catch(() => 'session2-failed')
await new Promise<void>((resolve) => setTimeout(resolve, 0))
// Session 1's POST fails — stale rejection fires; must NOT null out session 2's recovery
rejectSession1Create(new Error('network error'))
await session1Done
// Session 2's POST resolves successfully
resolveSession2Create({
ok: true,
statusText: 'OK',
json: () => Promise.resolve({ id: 'id-2' })
} as Response)
const session2Result = await session2Done
// Session 2 must succeed, proving its recovery was not nulled by session 1's rejection
expect(session2Result).toBe('session2')
})
it('does not skip re-provisioning when createCustomer resolves after sign-out', async () => {
let resolveCreate!: (value: Response) => void
const slowCreateP = new Promise<Response>((resolve) => {
resolveCreate = resolve
})
let postCount = 0
mockFetch.mockImplementation((url: string, init?: RequestInit) => {
if (url.endsWith('/customers') && init?.method === 'POST') {
postCount++
return postCount === 1
? slowCreateP
: Promise.resolve(mockCreateCustomerResponse)
}
return Promise.resolve(makeConflictResponse())
})
// Session 1 triggers recovery with a slow POST
const session1Done = store
.fetchWithCustomerRecovery('https://api.test/customers/balance')
.catch(() => {})
await new Promise<void>((resolve) => setTimeout(resolve, 0))
// User signs out before the POST resolves
authStateCallback(null)
authStateCallback(mockUser)
// Stale POST resolves successfully after session reset
resolveCreate!({
ok: true,
statusText: 'OK',
json: () => Promise.resolve({ id: 'stale-id' })
} as Response)
await session1Done
// A fresh recovery for the new session must POST again;
// customerCreated must not have been set by the stale resolution.
await store.fetchWithCustomerRecovery(
'https://api.test/customers/balance'
)
expect(postCount).toBe(2)
})
})
})

View File

@@ -87,6 +87,14 @@ export const useAuthStore = defineStore('auth', () => {
const currentUser = ref<User | null>(null)
const isInitialized = ref(false)
const customerCreated = ref(false)
/**
* Memoizes the in-flight or successful customer provisioning attempt for
* the current account (see recoverMissingCustomer). Declared here so the
* auth-state listener below can reset it before its initializer would
* otherwise run.
*/
let customerRecovery: Promise<void> | null = null
let customerRecoveryUid: string | undefined
const isFetchingBalance = ref(false)
// Balance state
@@ -152,6 +160,13 @@ export const useAuthStore = defineStore('auth', () => {
// Reset balance when auth state changes
balance.value = null
lastBalanceUpdateTime.value = null
// Customer provisioning state is per-account: without this reset, a
// second account in the same browser session would be short-circuited by
// the previous account's memoized recovery and stay stuck on 409s.
customerCreated.value = false
customerRecovery = null
customerRecoveryUid = undefined
})
// Listen for token refresh events
@@ -292,15 +307,14 @@ export const useAuthStore = defineStore('auth', () => {
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const response = await fetchWithUnifiedRemint(
const response = await fetchWithCustomerRecovery(
buildApiUrl('/customers/balance'),
{
headers: {
...authHeader,
'Content-Type': 'application/json'
}
},
isCloud && flags.unifiedCloudAuthEnabled
}
)
if (!response.ok) {
@@ -329,6 +343,7 @@ export const useAuthStore = defineStore('auth', () => {
const createCustomer = async (
payload?: CreateCustomerPayload
): Promise<CreateCustomerResponse> => {
const sessionUserId = currentUser.value?.uid
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
@@ -366,9 +381,126 @@ export const useAuthStore = defineStore('auth', () => {
)
}
if (currentUser.value?.uid === sessionUserId) {
customerCreated.value = true
}
return createCustomerResJson
}
/**
* Memoizes the customer provisioning attempt so concurrent or repeated 409
* responses from /customers/* endpoints trigger at most one successful
* POST /customers per account. A failed attempt is cleared so a later
* request can retry, e.g. after a transient network failure.
*/
const recoverMissingCustomer = (): Promise<void> => {
const sessionUserId = currentUser.value?.uid
if (customerRecovery === null || customerRecoveryUid !== sessionUserId) {
const thisRecovery: Promise<void> = createCustomer()
.then(() => undefined)
.catch((error: unknown) => {
if (customerRecovery === thisRecovery) {
customerRecovery = null
customerRecoveryUid = undefined
}
throw error
})
customerRecovery = thisRecovery
customerRecoveryUid = sessionUserId
}
return customerRecovery
}
/**
* Fetch wrapper for /customers/* endpoints that self-heals accounts whose
* customer record was never provisioned.
*
* Customer creation runs after the Firebase session is established during
* sign-in/sign-up, so an interruption (navigation, closed window, network
* failure) can leave a permanently signed-in user without a customer
* record. Sessions restored from persisted credentials never re-run the
* sign-in flow, so every /customers/* request fails with 409 and nothing
* ever retries the creation.
*
* On a 409 response this provisions the customer record (deduplicated
* across concurrent callers) and retries the original request a single
* time. If recovery fails, the original 409 response is returned so
* callers surface their normal error handling.
*/
/**
* The auth middleware rejects requests for accounts without a customer
* record using this exact message. Business-level 409s from /customers/*
* endpoints (e.g. conflicting subscription state) must NOT trigger
* provisioning or a blind retry of a payment request.
*/
const MISSING_CUSTOMER_MESSAGE = 'Failed to find customer'
const isMissingCustomerResponse = async (
response: Response
): Promise<boolean> => {
if (response.status !== 409) return false
try {
const body: unknown = await response.clone().json()
return (
typeof body === 'object' &&
body !== null &&
'message' in body &&
(body as { message: unknown }).message === MISSING_CUSTOMER_MESSAGE
)
} catch {
return false
}
}
const isCustomerEndpoint = (input: string): boolean => {
try {
const { pathname } = new URL(input, window.location.href)
return pathname === '/customers' || pathname.startsWith('/customers/')
} catch {
return false
}
}
const fetchWithCustomerRecovery = async (
input: string,
init?: RequestInit
): Promise<Response> => {
const remintFetch = (): Promise<Response> =>
fetchWithUnifiedRemint(
input,
init ?? {},
isCloud && flags.unifiedCloudAuthEnabled
)
const response = await remintFetch()
if (
!isCustomerEndpoint(input) ||
!(await isMissingCustomerResponse(response))
) {
return response
}
try {
await recoverMissingCustomer()
} catch (error) {
console.warn(
'Customer provisioning during 409 recovery failed; returning original response',
error
)
return response
}
try {
return await remintFetch()
} catch (error) {
console.warn(
'Retry after customer provisioning failed; returning original 409 response',
error
)
return response
}
}
const executeAuthAction = async <T>(
action: (auth: Auth) => Promise<T>,
options: {
@@ -530,13 +662,14 @@ export const useAuthStore = defineStore('auth', () => {
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
}
// Ensure customer was created during login/registration
// Ensure customer was created during login/registration. Routed through
// recoverMissingCustomer so a concurrent 409-triggered recovery and this
// pre-flight share one POST /customers instead of racing.
if (!customerCreated.value) {
await createCustomer()
customerCreated.value = true
await recoverMissingCustomer()
}
const response = await fetchWithUnifiedRemint(
const response = await fetchWithCustomerRecovery(
buildApiUrl('/customers/credit'),
{
method: 'POST',
@@ -545,8 +678,7 @@ export const useAuthStore = defineStore('auth', () => {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBodyContent)
},
isCloud && flags.unifiedCloudAuthEnabled
}
)
if (!response.ok) {
@@ -574,7 +706,7 @@ export const useAuthStore = defineStore('auth', () => {
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const response = await fetchWithUnifiedRemint(
const response = await fetchWithCustomerRecovery(
buildApiUrl('/customers/billing'),
{
method: 'POST',
@@ -585,8 +717,7 @@ export const useAuthStore = defineStore('auth', () => {
...(targetTier && {
body: JSON.stringify({ target_tier: targetTier })
})
},
isCloud && flags.unifiedCloudAuthEnabled
}
)
if (!response.ok) {
@@ -621,6 +752,7 @@ export const useAuthStore = defineStore('auth', () => {
register,
logout,
createCustomer,
fetchWithCustomerRecovery,
getIdToken,
loginWithGoogle,
loginWithGithub,