Compare commits

...

150 Commits

Author SHA1 Message Date
comfydesigner
f5a03b0ae0 feat: freeze auto-reload while subscription is paused
A paused (suspended) subscription can't spend, so auto-reload can't
fire — dim and disable the section the same way the lapsed state does,
rather than showing a misleading enabled/healthy tile. Extends the
frozen prop to cover paused alongside inactive.
2026-07-09 13:22:36 -07:00
comfydesigner
b1fc0e5c76 feat: hide invoice history table, defer to Stripe
Per-invoice download isn't feasible from our data, and Stripe already
owns the full invoice list + downloads, so the Invoices tab now just
shows the next charge and a Full invoice history link out. Drops the
local table, pagination, and its search box (kept useWorkspaceInvoices
for the next-invoice amount and easy restore).
2026-07-09 12:08:48 -07:00
comfydesigner
ea660e71ad fix: stack dense Credits-tab rows at a wider breakpoint
The credits/snapshot grid, plan header, and auto-reload only sit side
by side comfortably on the wide full-settings modal, not the narrower
standalone billing dialog, so raise their stack breakpoint from @2xl
(672px) to @4xl (896px). Lighter chrome (tabs, banners, footers) stays
at @2xl. Also keep the plan credit figure from breaking mid-value.
2026-07-09 01:34:12 -07:00
comfydesigner
45655af67b feat: responsive settings panels via container queries
Below a ~672px panel width (container @2xl), row layouts stack: the
tab bar drops its search to full width, the Credits plan header +
credits/snapshot grid + auto-reload go single-column, and the Activity
footer, invoice banner, and status banners stack their actions. Data
tables now nowrap all cells and scroll horizontally as a unit instead
of wrapping body text. Uses container queries (not viewport) so the
layout tracks the modal's actual width.
2026-07-09 01:27:20 -07:00
comfydesigner
c2d3d1dcb4 fix: stop table header labels wrapping to two lines
Header cells now nowrap, so a tight column lets the table scroll
horizontally (via the existing overflow-auto wrapper) instead of
wrapping the label. TableHead is used only by the workspace billing
tables, so this is scoped to those charts.
2026-07-09 01:05:47 -07:00
comfydesigner
0f15fa1972 feat: disable auto-reload in inactive subscription state
A lapsed plan can't auto-reload, so the section now reads Disabled
(toggle forced off) rather than just dimmed. AutoReloadSection gains a
frozen prop that owns the dim, blocks interaction, and forces the
off state, replacing the parent wrapper's dim so the tile no longer
double-dims.
2026-07-09 00:53:20 -07:00
comfydesigner
52e5f50b05 feat: redesign inactive team-subscription state
Per updated Figma (4453-24226): drop the $0/mo price line and the
'everything in Pro' value-prop panel. The credits tile now shows its
full breakdown frozen/dimmed via a new CreditsTile 'frozen' prop
(mirrors the paused treatment), and auto-reload stays visible but
dimmed instead of hidden. Header uses Manage payment + Reactivate.
2026-07-09 00:46:04 -07:00
comfydesigner
ab29ea72ed feat: plan-ending billing banner
A cancelled-but-still-active team plan now surfaces a calm, owner-only
notice: muted circle icon, 'Your team plan ends on {date}', and a
low-key secondary Reactivate plan action wired to useResubscribe. Sits
last in banner priority since it's an intentional, non-blocking state,
not a problem.
2026-07-08 21:39:32 -07:00
comfydesigner
b00c9f2bb9 fix: amber triangle for paused billing banner
A paused subscription is the escalation of a failed payment (credits
frozen, 'update payment to resume'), so it's an action-needed problem
like out-of-credits and payment-failed, not an informational notice.
All current banners now share the triangle-alert; the circle/muted
notice styling returns with the plan-ending banner.
2026-07-08 21:33:06 -07:00
comfydesigner
5b32c865ac feat: drop seat count from invite-limit tooltip
Matches the count-free member-limit dialog so no UI names a specific
seat number for enterprise or a changed cap.
2026-07-08 21:28:33 -07:00
comfydesigner
5bec5a06a8 fix: consistent severity icons on billing banners
Triangle-alert (amber) now marks every action-needed problem, so
payment-failed matches out-of-credits instead of showing the circle;
circle-alert (muted) is reserved for informational status notices like
paused. Collapses the icon and color into one severity condition.
2026-07-08 21:27:03 -07:00
comfydesigner
67d49de539 feat: drop hard seat count from member-limit dialog copy
The member-limit dialog no longer names a specific seat number, so it
needs no change for enterprise or if the cap moves, and it now notes
that pending invites count toward the limit (rescind an invite).
showMemberLimitDialog drops its now-unused maxSeats argument.
2026-07-08 21:17:54 -07:00
comfydesigner
141f2d933f feat: inactive team subscription state on Credits tab
Lapsed team/enterprise plans now show a reactivation header (title, 0
USD/mo, subtitle, Manage billing + Reactivate plan) and a plan
value-proposition panel in place of the live plan header and member
snapshot. Auto-reload is hidden while inactive. Reactivate wires to the
existing useResubscribe flow.
2026-07-08 21:15:30 -07:00
comfydesigner
c69d36a130 revert: drop the usage bar from the account popover
Removes the monthly-usage progress bar under the credit balance in the user
popover — not needed. The shared usage math stays in useSubscriptionCredits
(still consumed by the credits tile).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:32:22 -07:00
comfydesigner
2d6aea2d8f fix: stop auto-page-size from overflowing by a partial row
Measure row/header heights with getBoundingClientRect (fractional) instead
of offsetHeight (integer). Truncated integer heights made the fit calc place
one row too many, overflowing the scroll container by a few pixels and
showing a scrollbar. Fractional heights keep a not-quite-fitting row out,
leaving at most ~1 row of empty space.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:24:26 -07:00
comfydesigner
fc3bb1311a feat: show the credits usage bar in the user popover
Adds the monthly-usage progress bar under the credit balance in the account
popover (paid, active plans only), matching the design. Centralizes the
allowance-total and usage math in useSubscriptionCredits so the popover and
the credits tile share one source instead of duplicating it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:19:31 -07:00
comfydesigner
c5b92e1419 fix: keep the credits footer floated to the bottom
Restore mt-auto so the footer floats to the panel's bottom edge; the earlier
pb-10 -> pb-6 change is what aligns it with the other tabs' footers, not
dropping the float.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:11:28 -07:00
comfydesigner
36787eb6c3 style: rebalance activity table user/event-type widths
Pin the User column to a fixed width and let Event type flex to absorb the
table's slack, instead of User (with its short names) soaking up all the
empty space.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:09:57 -07:00
comfydesigner
4e45d69686 style: drop last-row divider and tighten credits footer gap
- Remove the trailing divider on the last table row across all five tables
  so it doesn't double up against the container edge.
- Drop mt-auto from the credits-tab footer so it follows the content with the
  normal gap instead of floating to the panel's bottom edge, and match the
  bottom padding to the other tabs (pb-6).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:06:24 -07:00
comfydesigner
ae4075b46c style: soften dividers to /20 and drop hover on read-only tables
Row dividers go from /20... down from /30 to /20. Also removes the row hover
highlight on the read-only tables (Activity, Invoices, Members, overview
snapshot) since those rows aren't interactive; the Allowlist keeps its hover
because its rows are selectable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:00:50 -07:00
comfydesigner
c1fc014d34 style: soften table row dividers
Drop the row divider opacity from /60 to /30 so the lines read as subtle
separators; the header/label divider stays at /60.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 18:54:15 -07:00
comfydesigner
d902343c91 refactor: use divider rows across the workspace tables
Replaces the alternating zebra backgrounds (and the plain / hover-pill
variants) with shadcn-style divider lines plus a subtle hover highlight, so
Activity, Invoices, Members, Allowlist, and the overview snapshot all share
one consistent row treatment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 18:51:55 -07:00
comfydesigner
b66ad926ab fix: left-align activity hover cards to their trigger
Anchor the user and partner-node hover cards to the start of their trigger
(align="start") so the popover's left edge lines up with the row content,
instead of the default centered placement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 18:33:10 -07:00
comfydesigner
04d371c581 feat: emphasize add-credits when the tile is fully out of credits
Restores the inverted (emphasized) add-credits button when monthly credits
are depleted and no additional credits remain; spending-additional keeps the
quieter tertiary button, and paused stays tertiary + disabled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 18:28:37 -07:00
comfydesigner
72800913e1 chore: rename billing-mock past_due state to at_risk
"past_due" is Stripe jargon that reads oddly next to the other subscription
statuses; "at_risk" better conveys "payment failing, about to pause."

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 18:23:02 -07:00
comfydesigner
d558296f3d feat: dim the credits tile while the subscription is paused
Paused subscriptions can't spend credits, so fade the whole tile to read as
frozen and defer to the Update-payment banner — matching how the auto-reload
tile dims when paused. Only applies to the paused state, not the at-risk
grace period.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 18:22:41 -07:00
comfydesigner
e12dea7af1 feat: add empty state to the recent activity snapshot tab
The snapshot tile now shows a per-tab empty state: the existing
"No credits used yet this month" for Top spenders, and a new
"No activity yet" for Recent activity when there are no events.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 18:16:20 -07:00
comfydesigner
698c74b458 refactor: use lucide-coins for the credits icon and refresh banner copy
Swaps every credits icon from lucide-component to lucide-coins across the
credit displays (top-up dialogs, user popovers, pricing table, credit badge,
node search, partner node cost). Also updates the out-of-credits banner copy
to the softer "add more credits to continue generating or wait until credits
refill" wording.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 17:52:06 -07:00
comfydesigner
1f48c6979e fix: remove duplicate out-of-credits banner on the members tab
The members tab had its own MembersOutOfCreditsBanner, which now duplicates
the out-of-credits variant added to the shared BillingStatusBanner. Drop the
members-specific banner, its useMembersPanel logic, the orphaned component,
its i18n keys, and the tests that covered it — the shared banner now handles
this state consistently across all tabs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 17:49:16 -07:00
comfydesigner
a52cb209fe fix: add bottom padding to members and allowlist tabs
These panels run their own layout (not through PlanCreditsPanelContent), so
they missed the tab bottom padding and sat flush to the dialog edge. Add
pb-6 to match the Activity/Invoices tabs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 17:37:48 -07:00
comfydesigner
f2f3e3652d feat: move out-of-credits messaging to the status banner
Adds an out-of-credits variant to the shared billing status banner (owner/
admin only, dismissible), slotting into the same top banner in priority
order paused → payment-failed → out of credits. In exchange the credits
tile drops its punch-out empty-state notices, so its depleted/out-of-credits
states read lean like the paused state (no inset toast, tertiary add-credits
button). Prunes the now-unused subscription notice i18n keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 17:24:53 -07:00
comfydesigner
eac08385a1 fix: lower table row floor to 1 and trim tab bottom padding
- Activity/Invoices tables fit rows down to 1 instead of forcing a floor of
  5, so short screens paginate instead of overflowing with a scrollbar.
- Reduce the Activity/Invoices bottom padding from 40px to 24px to match the
  top and reclaim vertical space.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 16:33:33 -07:00
comfydesigner
562886d1dc feat: add enterprise tier to the billing mock and plan name
Adds an "enterprise" option to the billing mock harness tier picker
(subscription_tier ENTERPRISE) and relabels the team plan as "Enterprise"
wherever the team plan name shows — the Credits-tab plan header and the
cloud subscription panel. Layout, credits, and seats are unchanged; the
team credit stop still drives the credits bar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 16:31:15 -07:00
comfydesigner
c35c6f2871 feat: simplify paused credits tab per design
- Keep the percentage on the allowance row when paused (e.g. "0% used")
  instead of swapping in "Refill paused"; drop the now-unused key.
- Replace the plan header's "Renews on {date}" with "Paused" when the
  subscription is paused.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 16:20:37 -07:00
comfydesigner
c1f393de2e feat: surface full invoice history in the paused billing banner
Instead of dropping the "Full invoice history" action when the next-invoice
banner is hidden on pause, add it as a ghost button beside "Update payment"
in the billing status banner (via a new actions slot), shown only on the
paused Invoices tab. Reverts the footer fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 16:16:35 -07:00
comfydesigner
678f9e06e4 feat: rename first tab to Credits and gate next-invoice on paused
- Rename the Plan & Credits first tab "Overview" -> "Credits".
- Drop the "Next month invoice" tile from that tab; it now lives only on
  the Invoices tab, removing the duplication.
- On the Invoices tab, hide the next-invoice banner when the subscription
  is paused (the parent "Subscription paused" banner covers that state) and
  fall back to a footer "Full invoice history" link so it stays reachable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 16:11:28 -07:00
comfydesigner
57aca543b2 feat: add next-month-invoice banner to the invoices tab
Mirrors the overview banner at the top of the Invoices tab per design:
muted "Next month invoice" label, 24px semibold amount with a 16px USD
suffix, and the "Full invoice history" action moved from the footer into
the banner as a secondary button. Footer keeps only pagination.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:48:04 -07:00
comfydesigner
7b808f2674 fix: give the paused auto-reload badge the inverted treatment
Paused is an alert state; use the high-contrast (inverted) badge so it
stands out, keeping the quieter secondary badge for the "off" state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:43:40 -07:00
comfydesigner
92c0f8f4c9 fix: match auto-reload amount size to the credits tile total
The reload credits number now uses the same 24px treatment as the total
credits number, per design.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:42:09 -07:00
comfydesigner
878b540b40 feat: track full-year allowance on the credits bar for annual plans
Annual subscribers receive the whole year's credits upfront, so the bar's
total is now the monthly nominal times the cycle length (x12 for annual) and
the "% used" math follows. Renames the monthly-prefixed internals to
allowance/cycle and makes the progress and "used after" copy cycle-neutral.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:41:03 -07:00
comfydesigner
b9ad439544 feat: update auto-reload budget tile to match design
Budget section now shows a color-coded "{percent}% spent" on the right of
the Monthly budget row (muted → amber near full → red when paused/full) and
a single "{spent} of {budget}" sub-label under the bar, with a divider above
it. Drops the left spent label and the reloads-left line, and prunes the
now-unused tile i18n keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:34:34 -07:00
comfydesigner
c26d7e0063 feat: fit snapshot rows to the credits tile height
Both overview tiles stretch to equal height via the grid; the credits tile
sets that height and the top-spenders tile now fits as many user rows as the
space allows (measured with useAutoPageSize), keeping "See more" pinned to
the bottom. Raises the snapshot pool so a taller tile can show more rows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:29:35 -07:00
comfydesigner
4bfce5fac3 fix: mute allowance label and vary it by billing duration
The credits-bar cycle label is now muted like the surrounding labels and
reads "Yearly" on annual subscriptions, "Monthly" otherwise.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:24:14 -07:00
comfydesigner
10f8271b8e fix: swap credit tile font sizes to match design
Plan header monthly grant is the 16px line; the Total credits balance is
the prominent 24px number. Had these inverted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:22:07 -07:00
comfydesigner
0c2fa9afc4 feat: simplify credits + auto-reload tiles per updated design
- Plan header shows monthly credit grant (coins) instead of $/mo price;
  next-month invoice already covers the dollar amount.
- CreditsTile: total balance uses 16px text; monthly row shows a single
  "{percent}% used" status (or "Refill paused") in place of the
  "used / left of total" breakdown and per-row refills date.
- AutoReloadSection: reload amount uses 16px text; drop the recent-reload
  history block and its now-unused helpers.
- Prune orphaned subscription i18n keys (refillsDate, refillsNextCycle,
  creditsUsed, creditsLeftOfTotal); add percentUsed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:13:53 -07:00
comfydesigner
33233a3aed chore(workspace): rename Activity inflow event to "Additional credits added"
Was "Credit top-up".
2026-07-08 13:55:25 -07:00
comfydesigner
a9eee9e14c feat(workspace): make Activity a credit ledger (inflows + usage)
Add auto-reload / top-up inflow events to the activity feed: they render '—' in
the User column (workspace-level, not a person), '—' for details, and a gold
'+N' credit amount to distinguish adds from usage. Rename the column "Credits
used" -> "Credits" since it now shows both directions. Inflows are excluded from
per-user rollups and stay workspace-level even in a member's scoped view.
2026-07-08 13:39:49 -07:00
comfydesigner
44d698678a test: align CreditsTile + MembersPanelContent tests with billing changes
MembersPanelContent now reads useBillingContext (isPaused) and renders
BillingStatusBanner — mock the context and stub the banner. CreditsTile's mock
gains isPaused, and its stale add-credits variant expectation is updated to
'tertiary' to match the component.
2026-07-08 13:16:24 -07:00
comfydesigner
cb7ef058a4 chore(workspace): expose aria-sort on sortable table headers
Add aria-sort (ascending/descending/none) to the Activity, Invoices, Members,
and Partner-nodes column headers so screen readers announce the active sort
column and direction. Adds a small ariaSort() helper alongside each sortIcon().
2026-07-08 13:08:39 -07:00
comfydesigner
fae8e049df chore(workspace): associate auto-reload dialog labels with their inputs
Add for/id on the threshold and reload fields and aria-labelledby on the budget
field so screen readers announce each input's purpose.
2026-07-08 12:42:01 -07:00
comfydesigner
53ad3d681d chore: address CodeRabbit nits — locale-aware formatting, a11y, api timeout
- Route currency/date through vue-i18n n()/d() instead of hardcoded en-US
  (invoices, overview, auto-reload dialog + section, overview renewal date)
- a11y: scope="col" on table headers, real alt on workspace avatar, role=status
  on the billing banner
- partnerNodesApi: 10s request timeout so a hung call surfaces the error toast
- overview: lucide ellipsis instead of PrimeIcons, locale-aware Learn more link
2026-07-08 12:40:16 -07:00
comfydesigner
c90babcf73 fix: address CodeRabbit review — correctness fixes + isPaused dedup
- AutoReloadDialogContent: ?? instead of || for numeric defaults (0 is valid)
- useAutoReload: budgetUsedFraction null-check consistent with hasBudget (0 budget)
- SettingDialog: flush content-padding for all workspace panels, not just Plan&Credits
- WorkspaceMembersPanelContent: void the onMounted fetches (no floating promises)
- usePartnerNodes: restore original last_modified on failed enable/bulk rollback
- lift isPaused into useBillingContext; drop the duplicated derivations in
  CreditsTile / MembersPanelContent / BillingStatusBanner
2026-07-08 11:55:16 -07:00
comfydesigner
ce73c2480f feat(workspace): rename Partner Nodes tab to Allowlist with shield-check icon
The section will host both partner-node and model allowlists (as sub-tabs) later,
so rename the sidebar entry to the umbrella "Allowlist" and swap the node-specific
icon for shield-check. Internal keys/routes unchanged.
2026-07-07 19:39:10 -07:00
comfydesigner
3cb63e3209 feat(workspace): hover card showing the partner node used on activity rows
Add a HoverCard on the 'Partner node usage' event-type cell in the Activity tab
that reveals which partner node was used ("Partner node used -> Nano Banana
Pro"), mirroring the existing per-user hover card. Mock the node per event.
2026-07-07 19:14:36 -07:00
comfydesigner
7317b73a38 style(workspace): pull invoices Event type next to Date
Fix the Date column width (w-40, matching Activity) so Event type no longer
splits the leftover space with it and sits right beside Date, per the Figma.
2026-07-07 19:10:47 -07:00
comfydesigner
beb0a5e12b fix(workspace): overlay partner-nodes selection bar to stop panel reflow
The SelectionBar's in-flow wrapper was a child of the gap-4 flex column, so
toggling it in added a gap and nudged the bottom rows. Anchor the panel and
overlay the bar (absolute) so it floats without participating in layout.
2026-07-07 19:08:44 -07:00
comfydesigner
0124ea8d43 refactor: extract reusable SelectionBar, use it for partner nodes
The floating bulk-selection bar built for Media Assets (PR #13043) is the same
shell the partner-nodes tab needs — only the action differs. Extract the shell
(inverted floating pill: deselect, count, right-aligned actions slot) into a
shared SelectionBar; MediaAssetSelectionBar now composes it (download/delete in
the slot), and the partner-nodes tab uses it with a bulk enable/disable Switch.
2026-07-07 18:29:33 -07:00
comfydesigner
0be144d1d6 feat(subscription): distinct paused state for the credit tile
When the subscription is paused (failed payment), the zeroed balance was falling
into the generic out-of-credits UI. Give paused its own state: drop the
out-of-credits notice, show "Refill paused" + an empty bar (0 used), and disable
Add credits — since topping up doesn't help until payment is resolved. Out of
credits stays independent of the paused/payment-failure state.
2026-07-07 17:43:24 -07:00
comfydesigner
77330e8f60 feat(workspace): always show partner-nodes select-all checkbox
Keep the header select-all visible instead of only after a selection exists, so
bulk-selection is discoverable up front (Gmail/Linear pattern). Row checkboxes
stay hover-revealed.
2026-07-07 17:34:11 -07:00
comfydesigner
8fb73ff290 fix(workspace): place billing banner below panel headers, dedupe when paused
Move the billing banner below the header/tab row in Members and Partner Nodes so
it sits in the same spot as Plan & Credits (under the tabs) instead of above.
When the subscription is paused the balance is zero, which also tripped the
Members out-of-credits banner — suppress it while paused so only the paused
banner shows.
2026-07-07 17:32:50 -07:00
comfydesigner
674143721f fix(workspace): dark billing banner with inverted button
Keep the banner on the dark surface; use the inverted (white) button variant so
the Update payment action is what catches the eye, per the Figma.
2026-07-07 17:28:11 -07:00
comfydesigner
52c7944f2c style(workspace): invert billing banner, clarify harness field labels
Use the inverted (light-on-dark) treatment for the payment banner so it draws
the eye against the dark panel. Rename harness picker labels 'state' -> 'subscription
state' and 'balance' -> 'credit balance' (labels are display-only, decoupled from
the config keys).
2026-07-07 17:13:12 -07:00
comfydesigner
6dcc34b595 feat(workspace): payment-failed / paused billing banner across workspace tabs
Add a BillingStatusBanner that surfaces a payment failure on every workspace
settings tab (Plan & Credits sub-tabs, Members, Partner Nodes), per the Figma
annotation. Two states: payment declined (grace-period warning, owner/admin only
since members can't act) and subscription paused (owners/admins get an Update
payment action; members get an informational variant pointing to admins). Reads
the existing billing_status / subscription_status the context already exposes.

Add 'paused' to BillingSubscriptionStatus and two harness states (past_due,
paused) to drive it; paused also zeroes the balance.
2026-07-07 16:57:54 -07:00
comfydesigner
bef5616aba fix(workspace): clamp billing-mock panel into viewport on load
The panel persists its dragged left/top, but on load it applied them raw, so a
position saved on a larger display landed off-screen on a smaller one with no
header to grab. Re-clamp the persisted position to the current viewport.
2026-07-07 16:38:29 -07:00
comfydesigner
7b7cd0342f style(workspace): widen overview footer link gap to 16px
gap-2 -> gap-4 per updated spacing.
2026-07-07 16:36:22 -07:00
comfydesigner
8d8d0ed61d fix(workspace): reserve partner-nodes scrollbar gutter, dedupe node names
Swap the hidden scrollbar for a reserved gutter (scrollbar-gutter: stable) so the
list keeps a visible scrollbar without the rows shifting when it appears; mirror
the gutter on the auto-enable row so its toggle stays aligned with the column.

Dedupe partner nodes by display name (5 collisions, e.g. two "OpenAI GPT Image
2") so the governance table has no indistinguishable duplicate rows: 213 -> 208.
2026-07-07 16:24:35 -07:00
comfydesigner
d7e074bbf5 fix(workspace): float overview footer to bottom, hide partner-nodes scrollbar
The overview footer links now use mt-auto so they pin to the bottom of the panel
when the content is short (a member's near-empty tab) and scroll below the
content when it overflows — a pure-flexbox sticky footer, no height math.

The partner-nodes table is the only settings table that isn't paginated, so its
scrollbar was the lone visible one in the dialog and its width shifted the row
toggles out of line with the auto-enable toggle. Hide it (scrollbar-hide, as the
overview already does) so the columns line up again.
2026-07-07 16:19:33 -07:00
comfydesigner
4daf17c9d7 fix(workspace): list the full partner-node catalog in the governance mock
The canned list only had ~96 nodes — roughly the first page of the API-node
catalog alphabetically — so everything after "Luma" (OpenAI, Runway, Recraft,
Tripo, Veo, Stability AI, Meshy, Rodin, Vidu, Wan, etc.) was missing. Replace it
with the full 213-node set, matching what the node-search "Partner" filter shows,
labelled by the node's partner category.
2026-07-07 16:11:49 -07:00
comfydesigner
ab275ba35e fix(workspace): even partner-node row fill + reactive auto-enable label
The disabled-node opacity was on the whole TableCell, dimming the selection/hover
fill on the name and partner cells while the other cells stayed full — an uneven
row fill. Dim only the cell content so the fill is uniform.

Make the auto-enable footer label reflect the switch (auto-enabled/auto-disabled)
and stack both strings in one grid cell so the width is fixed and the row doesn't
reflow when it toggles.
2026-07-07 15:57:45 -07:00
comfydesigner
f5f365c58a fix(workspace): left-align overview footer links
Match Figma: cluster the footer links to the left with an 8px gap instead of
spreading them across the row.
2026-07-07 15:52:34 -07:00
comfydesigner
64bcf2748e Merge remote-tracking branch 'origin/main' into comfydesigner/team-workspaces-v1
# Conflicts:
#	src/platform/cloud/subscription/components/CreditsTile.vue
2026-07-07 15:15:13 -07:00
comfydesigner
b2400f764d fix(workspace): drop trailing period from members usage label
Members (who can't invite) see just the count, so the trailing period reads as a
stray dangling mark; owners keep it as a separator before "Need more members?".
2026-07-07 15:06:53 -07:00
comfydesigner
4925f0ca0c style(workspace): space out overview footer links
Use justify-between so the footer links spread across the row.
2026-07-07 15:06:51 -07:00
comfydesigner
123bdad84c fix(workspace): drop unused AutoReloadScenario export
knip flags it as an unused exported type (the dev harness keeps its own local
copy of the union), which fails the pre-push hook. Scope it to the module.
2026-07-07 15:06:50 -07:00
comfydesigner
04e79dc2da feat(workspace): role-based visibility for members
- Members can view pending invites (view-only; no resend/revoke) and see just
  the Email/Role columns in the members table
- Hide billing from members: plan price, Manage payment, member snapshot, next
  invoice, the auto-reload section, and the Invoices tab
- Scope the Activity tab to the member's own usage and right-align its
  pagination; hide the per-user footer links
- Gate the members footer "Contact us" / request-more behind invite permission
2026-07-07 15:06:48 -07:00
comfydesigner
9b54efdef4 feat(workspace): raise seat limit to 50 and link request-more to the team-plan form
- MAX_WORKSPACE_MEMBERS 30 -> 50
- Member-limit dialog's "Request more" opens the team-plan request form
2026-07-07 15:06:46 -07:00
comfydesigner
cf3e4c4620 feat(workspace): redesign the auto-reload dialog
- Drop the subtitle; render Monthly budget as a divider band, not a boxed card
- Refine copy and collapse the minimum-amount error to a single string
- Size the Credits/USD toggle to match the Update button
2026-07-07 15:06:44 -07:00
comfydesigner
53c7566665 style(workspace): flush settings panels and tabular credit figures
- Add a 'flush' content-padding variant to BaseModalLayout; the settings
  dialog uses it for workspace panels so content runs to the bottom edge
- Tabular-nums on the CreditsTile figures so columns align
2026-07-07 15:06:43 -07:00
comfydesigner
001b9f3ace feat(workspace): auto-reload credits, table auto-sizing, Overview polish
- Credit auto-reload: section, tile states (healthy/near-limit/paused/off/
  empty), and configure dialog with Credits/USD toggle and $5 minimum
- Auto-size Activity/Invoices tables to the dialog height (useAutoPageSize)
- Activity footer: Full activity link + See Members deep-link
- Overview footer links wired (Learn more, Partner Nodes pricing, support)
- Overview snapshot empty state; future renewal date
- billingmock: balance "full" state, team_credit_stop scaling, auto-reload
  scenario picker (live, no reload) + stale-value sanitizing on load
- Plan & Credits nav icon -> receipt-text
2026-07-07 15:06:41 -07:00
comfydesigner
2324417e22 chore(billingmock): tie member usage to balance state, add partial
Zero every member's credits_used_this_month in the fully-funded state so
both Overview tiles agree that nothing has been spent, and add a 'partial'
balance option (now the default) where members reflect their mock usage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:39 -07:00
comfydesigner
9bbe75eeaf feat: snapshot uses Members data; See more deep-links per tab
Derive the Overview member snapshot from the workspace members store (Top
spenders by credits, Recent activity by last activity) so it matches the
Members tab. Wire See more: Top spenders opens the Members panel
pre-sorted by credit usage (via a settings-navigation helper + a one-shot
members sort), Recent activity opens the Activity tab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:37 -07:00
comfydesigner
0ac06403d8 style: strip native chrome from the additional-credits info button
Reset the raw info tooltip button's native appearance so it renders as a
plain muted icon (with a cursor-help + hover tint) instead of a filled
button box.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:36 -07:00
comfydesigner
786ae99afa style: unify disabled styling for workspace menu items
Drop the redundant per-item text-danger/50 and opacity-50 classes so
disabled Delete and Leave both render with the shared data-disabled muted
style; Delete stays red only when actually enabled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:34 -07:00
comfydesigner
1e2cfd54c8 style: restore 24x20 tile padding; tertiary Add credits button
Revert both Overview tiles to px-6 py-5 padding and switch the CreditsTile
Add credits button to the tertiary variant so it matches the See more
button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:33 -07:00
comfydesigner
b206ca457f style: match Overview credits tile padding to the snapshot tile
Pass p-4 to the Overview CreditsTile so its padding matches the member
snapshot tile (legacy CreditsTile usages keep px-6 py-5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:31 -07:00
comfydesigner
5dd04a5e3f refactor: mini snapshot table reuses shadcn Table; fix line tabs; tertiary See more
Rebuild the member-snapshot list with the shared Table component (zebra
rows, coins header, divider) so it mirrors the Activity/Invoices tables
in miniature. Reset the native button appearance on TabsTrigger and drop
the list border so the tabs render as clean underline tabs. Use the
tertiary button variant for See more so it stands out on the tile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:29 -07:00
comfydesigner
5e5bf8891a refactor: reuse CreditsTile in Overview; add shadcn line Tabs
Replace the hand-built Overview credits tile with the shared CreditsTile
(real billing data), swapping its credit icon to lucide--coins and
letting callers pass a class so the Overview can drop the border. Add a
shadcn-style line Tabs component and use it for the member-snapshot
Top spenders / Recent activity toggle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:27 -07:00
comfydesigner
e75f32b56f chore(billingmock): add admin role, map owner to current user, keep dialog open
Add an 'admin' role option (owner-role, non-creator) to the harness. For
'owner', assign the signed-in email to the creator member so the
original-owner gating (Change plan / Cancel plan) resolves true; 'admin'
keeps a separate creator and injects the current user as a non-original
owner. Stop pointerdown/focusin from bubbling out of the panel so
interacting with the picker no longer dismisses the settings dialog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:26 -07:00
comfydesigner
46a357113b feat: owner-only Cancel plan menu on the Overview plan header
Add a Cancel plan overflow menu (owner + active, non-cancelled, non-free
subscription) to the Overview plan header, matching the old design. Hide
the overflow menu and Change plan for non-owners (Admins/Members).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:24 -07:00
comfydesigner
d5ebe903bd style: use modal panel background for Overview tiles, drop borders
Match the credits and member-snapshot tiles to the modal sidebar color
(bg-modal-panel-background) and remove their outline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:22 -07:00
comfydesigner
c3c22d3e3a style: round zebra rows 4px and remove row hover on Activity/Invoices
Paint the alternating stripe on the cells (with 4px rounded end caps)
instead of the table row, so it can render a radius and no longer flickers
on hover. These read-only tables have no per-row interaction, so the
hover state is fully removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:21 -07:00
comfydesigner
93dc76728e style: make Overview footer 32px tall
Match the Overview footer-link row height to the other footers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:19 -07:00
comfydesigner
a0932ba40f fix: hide legacy Credits nav when workspace Plan & Credits is shown
The per-account Credits panel duplicated the new workspace Plan & Credits
entry in the Workspace sidebar group; suppress it when the workspace
panel is present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:18 -07:00
comfydesigner
af5e9d0766 feat: build Plan & Credits Overview (plan, credits, member snapshot)
Replace the Overview placeholder with a real layout: plan header
(price/renewal + actions), a credits tile (total/monthly/additional with
progress) beside a member-snapshot tile (Top spenders / Recent activity
+ See more), and a next-invoice card with footer links. Data is
client-side mock via useWorkspaceOverview; See more / Invoices navigate
to those tabs. Adds a small ProgressBar. Auto-reload section follows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:16 -07:00
comfydesigner
c3ca88161c feat: tabular date column, subtle zebra rows, 11 rows per page
Use tabular-nums on the Activity/Invoices date column, add a faint
alternating-row tint (matching the Figma) on those read-only tables, and
fit one more row per page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:14 -07:00
comfydesigner
e499bfc172 fix: prevent clipped table rows, tidy pagination and popover
Make the Activity/Invoices table area a graceful scroll region with a
sticky header (so a page can never clip its last row) and drop the page
size to 10 to fit the card. Render pagination with the shared Button
component, and use the semantic border token on the hover card.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:12 -07:00
comfydesigner
96ac9f04c0 fix: lift hover card above the settings dialog; add muted user badge colors
Share the hover card's open state so its content can lift past the
settings dialog's incrementing modal z-index (it was rendering behind
the dialog, so nothing appeared on hover).

Give user monogram badges a stable, muted low-saturation color from a
sampled palette across the Members, Pending, and Activity tables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:10 -07:00
comfydesigner
57f24854be style: 10px activity badge letters and larger page size
Shrink the Activity user badge letter to 10px (text-2xs), and bump
Activity/Invoices to 12 rows per page (with a longer mock list) now that
rows are ~40px tall.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:08 -07:00
comfydesigner
3f9f2a7782 feat: add user hover card to the Activity table
Add a shadcn-style HoverCard (HoverCard/Trigger/Content on reka-ui) and
use it on the Activity table's User cell to surface that user's lifetime
credits used and last activity, aggregated across all events.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:07 -07:00
comfydesigner
f5844a0a87 style: shrink activity user badges to 20x20
Use a 20px avatar in the Activity table (Members keeps 32px).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:05 -07:00
comfydesigner
f01448d6b9 fix: use static i18n keys for Plan & Credits tab labels
Resolve tab labels via static t() calls so the unused-key linter can
trace them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:03 -07:00
comfydesigner
3a31a99eaf feat: add Activity and Invoices views to Plan & Credits
Add Overview/Activity/Invoices sub-tabs to the Plan & Credits panel.
Activity and Invoices render shadcn tables (Date/User/Event/Credits and
Date/Event/Price) sourced from client-side mock data, sortable, with a
32px footer that pairs a history link with a reka-based Pagination
component instead of scrolling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:02 -07:00
comfydesigner
053d559d15 style: tighten out-of-credits banner to match Figma
Reset the body paragraph's default margin so the banner loses the extra
vertical whitespace and matches the compact Figma layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:00 -07:00
comfydesigner
8135842cce chore: expand mock roster so the members table scrolls
Grow the team workspace to a full roster (near the seat cap) so the
sticky-header scroll behavior and usage footer can be seen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:58 -07:00
comfydesigner
6ecb13a6ce chore: add more mock members to the team workspace
Expand the billing mock harness team roster to eight members with varied
roles, activity times, and monthly credit usage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:57 -07:00
comfydesigner
de95ef0fbd fix: make the Rename menu item start inline editing
Selecting Rename opened the editor, but reka returned focus to the menu
trigger on close, blurring the just-focused input and committing an empty
rename. Suppress that focus restoration for the close that starts a
rename so the input keeps focus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:55 -07:00
comfydesigner
f03cd5de33 style: match members footer height and Contact us button sizing
Fix the members footer to 32px tall (matching the partner nodes footer)
and bump Contact us to size md with 14px text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:53 -07:00
comfydesigner
529248fb7e style: show only the email on pending invite rows
Drop the derived display-name line; the email is the sole identifier
for a pending invite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:52 -07:00
comfydesigner
2c9bee1da3 style: letter avatars for members, drop row hover, round partner rows
Use letter-initial avatars in the Members table (matching pending
invites) and drop the unused photo/UserAvatar path. Remove the hover
highlight on member and pending-invite rows. Give partner-node rows a
4px radius by painting the hover/selected background on the cells with
rounded end caps (table rows can't render border-radius directly).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:50 -07:00
comfydesigner
ba494b3d4a fix: match sort-header font to page font in workspace tables
Native button headers didn't inherit the page font-family, so sortable
column labels rendered in a different font than the non-sortable Pending
Email span. Force font inheritance on the shared sort-header class.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:49 -07:00
comfydesigner
2b49d78637 fix: give invite dialog a fixed width and lighter email badges
The dialog hugged its content width, so it grew while typing. Pin it to
a fixed 33rem so emails wrap to new lines (height grows) instead. Raise
the email badge to tertiary-background-hover so it reads against the
input background.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:47 -07:00
comfydesigner
b579234d55 feat: mock pending invites and hide empty Pending count
Serve two mock pending invites (team workspaces) from the billing mock
harness, and show the Pending tab as plain 'Pending' with no count when
there are none.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:45 -07:00
comfydesigner
d9a5d28364 style: drop redundant Partner nodes header title
Remove the 'Partner nodes' title so the description moves up and the
table aligns vertically with the Members tab. Center the header row to
match the Members header.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:43 -07:00
comfydesigner
5ec65f366e style: use lg size for Members/Pending tab buttons
Match the tab buttons to the Invite button's size and styling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:42 -07:00
comfydesigner
5e9d1b89f9 feat: add member-limit and workflow-queued dialogs
Add a shared 'request more' dialog (title, message, Close + Request more)
and register both the 'workspace at member limit' and 'workflow queued'
variants in the dialog service. The invite button now stays enabled at
the seat cap and opens the member-limit dialog on click; the queued dialog
is previewable from the billing mock harness.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:40 -07:00
comfydesigner
4308498b79 feat: redesign Members panel as a table with activity and credits
Convert the active-members and pending-invites lists to the shadcn table
(matching Partner Nodes): columns Email, Role, Last activity, Credits
used this month, and a row overflow menu, with sortable headers. Move the
Members/Pending tabs up into the header row beside search and invite, and
add a per-seat usage footer.

Add an out-of-credits banner above the table, driven by the workspace
balance, with Dismiss and Add credits actions. Menus render non-modal so
they don't dismiss the settings dialog. Retire MemberListItem and
PendingInvitesList in favor of MemberTableRow and PendingInviteRow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:38 -07:00
comfydesigner
3bd7681e84 feat: add member last-activity and monthly-credit fields
Extend WorkspaceMember (and the optional API fields) with lastActivity
and creditsUsedThisMonth for the redesigned Members table, populate them
in the billing mock harness, and add an abbreviated relative-time helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:37 -07:00
comfydesigner
1e44a207b9 feat: add Rename to workspace menu and fix dialog dismissal
Add a 'Rename Workspace' item (above a divider) to the header overflow
menu, wired through a shared useWorkspaceRename composable so it and the
header double-click drive the same inline edit. Drop the menu-item icons
to match the Figma.

Fix the settings dialog closing when the menu trigger is clicked while
open: the dialog is non-modal but reka's DropdownMenu defaulted to modal,
which disables outside pointer-events and retargets the trigger click to
a non-whitelisted body element. Thread a modal prop through DropdownMenu
and render this menu non-modal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:35 -07:00
comfydesigner
ba99a98807 fix: match rename input font and pluralize char-limit hint
Force the inline rename input to inherit the page font-family so it
renders identically to the resting name heading (native controls fall
back to the UA font otherwise). Reword the character-limit hint to
'N characters left' with proper singular/plural forms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:33 -07:00
comfydesigner
98e42e53b7 feat(workspace): reduce workspace name limit to 30 characters
Extract a shared WORKSPACE_NAME_MAX_LENGTH constant (was 50, now 30) and use it
across the inline rename, create, and edit flows plus the validation copy, so
the limit stays consistent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:31 -07:00
comfydesigner
baf75706bd style(workspace): default cursor on the workspace name until editing
The resting name used a text cursor, implying it was already editable. Use the
default cursor; the input's native text cursor is the feedback once editing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:30 -07:00
comfydesigner
4242be2707 feat(workspace): rename shows a '{n} left' counter near the 50-char limit
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:27 -07:00
comfydesigner
5c7ccbf2a4 style(workspace): auto-enable footer to 32px, drop top padding
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:26 -07:00
comfydesigner
21923f4e9a feat(workspace): move delete/leave to a top overflow menu; rename-input polish
Remove the members-tab overflow (...) button — editing is now inline — and
relocate delete/leave to a WorkspaceMenuButton in the dialog header next to the
X (shown on every workspace panel). Drop the now-inline 'Edit workspace details'
item and hide the menu when it has no actions. Rename input: strip native
appearance/border/padding so the text no longer shifts on edit; enforce a
50-char limit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:24 -07:00
comfydesigner
6e67964317 feat(workspace): mock workspace image replace via hover pencil (owner/admin)
Add optional imageUrl to WorkspaceProfilePic (renders the image over the
gradient+initial fallback). In the settings header, hovering the avatar reveals
a pencil overlay (owner/admin only); clicking opens a file picker and shows the
chosen image as a local data-URL preview. Client-side only — no upload or
persistence, per the prototype scope.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:22 -07:00
comfydesigner
11be2af866 feat(workspace): inline rename in the settings header (owner/admin)
Double-click the workspace name to rename it inline (Enter/blur commits, Esc
cancels), gated to owners/admins with a 'Double-click to rename' tooltip; uses
the existing updateWorkspaceName. Make the mock harness route handlers
body-aware and add a PATCH /api/workspaces/:id route so the rename reflects.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:21 -07:00
comfydesigner
2c92b2cc6c style(workspace): members-header overflow button uses secondary-background
Drop the lighter selected-surface override so the members-header ... button
matches the search, invite, and close buttons (all secondary-background).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:19 -07:00
comfydesigner
ffb925837a style(ui): bump lg SearchInput text to 14px
The large SearchInput variant (settings content search, asset browser, sample
model selector) now uses text-sm (14px) instead of text-xs; the container
already has room. Other sizes unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:17 -07:00
comfydesigner
af2f09304d feat(workspace): drop partner filter for V1, tabular-nums on selection count
Search already matches partner names, so the separate partner filter is
redundant for V1 — remove it (and the now-unused composable state, i18n, dead
imports); revisit if requested. Use tabular-nums on the bulk-selection count so
the toolbar width barely moves as the number changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:16 -07:00
comfydesigner
b01c01625a fix(workspace): stop breaking full-color provider icons; taller header; unbold titles
The comfy icon set keeps multi-color brand icons and only maps monotone glyphs to
currentColor. Tinting the full-color ones (ByteDance/Kling/Luma/Gemini/Tencent)
replaced their artwork with a gradient square; only tint the monotone (solid)
providers now. Raise the Partner Nodes column header to 56px and unbold the
Partner Nodes / Members section titles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:14 -07:00
comfydesigner
fef7ad95f6 fix(dialog): clicking a menu trigger no longer dismisses the parent dialog
Reka treats the body-portaled dropdown content as outside the dialog, and the
outside-pointer handler only whitelisted overlay/menu content — not the trigger.
So reopening/closing a DropdownMenu (e.g. the Partner Nodes filter) via its
trigger dismissed the whole Settings dialog. Whitelist aria-haspopup triggers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:12 -07:00
comfydesigner
77624ad2b6 fix(ui): table scroll wrapper takes layout class; align auto-enable toggle
The Table component applied its class to the inner <table> instead of the scroll
wrapper, so the Partner Nodes scroll container was unconstrained and the sticky
header gap/height shifted on scroll. Apply the class to the wrapper and set
border-spacing:0 on the table so the sticky header sits flush. Pad the
auto-enable footer so its toggle lines up with the in-table row toggles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:11 -07:00
comfydesigner
2872fd4062 style(workspace): muted partner node names, disabled dim, brand-colored badges
Match the Figma partner nodes table: node names use muted-foreground; disabled
rows drop the name and partner to 30% opacity (date stays readable, toggle shows
its off state) instead of a flat row dim. Tint the monochrome provider icons
with their brand color (Anthropic coral #D97757, gradients like Kling painted
over the icon).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:09 -07:00
comfydesigner
6948ff3fc9 fix(workspace): partner nodes sticky header + divider
The Table component wraps its table in its own overflow-auto div, so nesting it
in a second scroll container made the sticky <thead> stick to the inner
non-scrolling div and scroll away. Use the Table as the single scroll container
and put the header divider on the <th> cells so it stays as the sticky header's
lower border. Move the empty state into a spanning row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:08 -07:00
comfydesigner
3e867da225 style(workspace): 16px table padding, circular partner badge with background
Reduce the Partner Nodes table inset from 24px to 16px per the Figma. Rebuild
the partner badge as a 20px circle (secondary-background-hover) with the 12px
provider icon centered so it fits without clipping, matching the Figma badge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:06 -07:00
comfydesigner
90d7d9f450 style(workspace): flat partner icons, ghost member-row overflow, members column divider
Partner badges now render the bare flat provider icon (no bordered pill). The
per-member overflow (...) button returns to the ghost variant. Add a divider
under the members column labels; the label row sits above the scroll area so it
stays visible while the list scrolls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:04 -07:00
comfydesigner
d52e97b0dc style(workspace): fix filter button shade, give members overflow button a fill
The Partner Nodes filter button dropped the lighter selected-surface override so
it uses the standard secondary surface, matching the adjacent search and the
dialog close button. The members-tab overflow (...) button gains the filled
surface used by the other overflow menus instead of being backgroundless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:02 -07:00
comfydesigner
dc298c9ab8 feat(workspace): real provider icons on partner badges; keep selection after bulk toggle
Rebuild PartnerBadge on the shared BadgePill + getProviderIcon/getProviderBorderStyle
so partner rows show the same brand icons the node search menu uses, instead of
colored-initial placeholders.

Stop clearing the partner-node selection after a bulk enable/disable — the rows
stay selected so the switch can be flipped again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:00 -07:00
comfydesigner
9c3d2d3731 feat(workspace): Members header above card, list scrolls inside
Match the Partner Nodes layout: the search/invite/menu header sits above the
outlined card; the card fills the modal height and the members list scrolls
inside it instead of the whole card clipping. Footer stays below.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:59 -07:00
comfydesigner
6f44b98153 feat(workspace): Partner Nodes header above card, table scrolls inside; icon/label polish
Move the Partner Nodes title + search/filter above the outlined card; the card
now fills the modal height and the table scrolls inside it with a sticky header,
instead of the whole card clipping. Nav icons: Plan & Credits uses
lucide--receipt-text, Partner Nodes uses the comfy--node sidebar icon. Lowercase
'Partner nodes'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:57 -07:00
comfydesigner
51c213997f feat(workspace): real partner node list, brand badges, hover-reveal selection
Add shadcn-vue Checkbox and use it in the Partner Nodes table. Checkboxes are
hidden until a selection exists; with none selected they reveal on row hover
(the header select-all appears only once selecting). Give the filter button a
filled surface instead of the ghost variant. Add a PartnerBadge (brand colour +
initial) in the Partner column.

Populate the mock harness with the real Comfy Cloud partner-node catalog (96
nodes across 15 partners) pulled from the api-nodes list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:55 -07:00
comfydesigner
14c94879b1 fix(workspace): stabilize settings header height, lift Partner Nodes footer out of card
Use a uniform h-22 (88px) settings header on every tab so the 40px close button
sits a constant 24px from the top edge and no longer shifts when switching tabs;
the 48px workspace avatar centers ~20px from the top.

Move the Partner Nodes 'auto-enable new nodes' toggle outside the table card and
drop the bottom divider, matching the Figma (footer pinned to the panel bottom).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:54 -07:00
comfydesigner
fd6ec16c72 style(workspace): soften card outlines, unify overflow buttons to filled
Soften the workspace card borders (Plan & Credits, Members, Partner Nodes) to
interface-stroke/60 for a lighter panel. Standardize the members-row overflow
(...) button on the filled secondary style used in Plan & Credits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:52 -07:00
comfydesigner
974d79f776 feat(ui): shadcn Table for Partner Nodes, row-click select, fix Switch
Add a shadcn-vue Table component family (Table/Header/Body/Row/Head/Cell) and
rebuild the Partner Nodes table on it. Rows now hover and are clickable to
toggle their checkbox (a bigger target than the checkbox); the toggle cell and
checkbox stop propagation so they don't double-fire. Row dividers use the
interface-stroke token at reduced opacity for a subtler rule.

Fix the Switch: the thumb was bg-base-background (dark on a dark track, nearly
invisible) — now a white thumb on a primary/interface-stroke track.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:50 -07:00
comfydesigner
6636851f79 fix(workspace): give the settings header 24px top breathing room
Add an optional headerHeightClass to BaseModalLayout (default h-18, unchanged
for other dialogs). SettingDialog bumps the header row to h-24 on workspace
panels so the 48px avatar sits 24px from the top while staying aligned with
the close button; nav and content headers move together.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:49 -07:00
comfydesigner
3655901721 fix(workspace): align settings header with the dialog close button
Render the workspace avatar + name into the settings dialog's header row so
they sit on the same level as the close (X) button, matching Figma. Size the
avatar to 48px and the name to 24px/600 per the heading-text-medium spec.

Also fix WorkspaceProfilePic to merge its base classes via cn() so a caller's
size utility (e.g. size-12) reliably wins over the component's default size-8
instead of colliding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:47 -07:00
comfydesigner
6d91432abe chore(billing): trim mock harness to workspace-relevant controls (do not merge)
Remove the on/off toggle (harness is always active once opted in via ?billingmock;
the X still fully deactivates it) and the V0 checkout/op-flow controls
(subscribe, topup, op-poll). Keep ws, role, tier, state, balance, roleChange,
and the 2nd-workspace switcher toggle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:45 -07:00
comfydesigner
2b8981a2d3 feat(workspace): split settings into sidebar entries + Partner Nodes governance
Break the combined Workspace settings panel into three Settings sidebar
entries: Plan & Credits (keeps the 'workspace' key so deep links land),
Members, and a new Partner Nodes governance screen. Partner Nodes is gated to
Owner + Admins via a new canManagePartnerNodes permission; Members never see it.

Partner Nodes: per-node + bulk enable/disable, search, partner filter, sort,
and a workspace default for auto-enabling newly added nodes, backed by a new
partnerNodesApi + usePartnerNodes composable with optimistic updates. Adds a
reusable Switch. Mock routes added to the (do-not-merge) billing harness.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:43 -07:00
comfydesigner
4779a21ce5 chore(billing): restore preview billing mock harness (do not merge)
Dev-only harness for exercising billing/workspace UX on a cloud proxy with no
BE writes. No-op unless opted in via ?billingmock. Restored from 75da9212d^ to
back the V1 team-workspace prototype; must be excluded from the split PRs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:42 -07:00
comfydesigner
6b0e12a783 feat(workspace): rename non-creator Owner role to Admin
Reserve 'Owner' for the workspace creator (is_original_owner); every other
owner-role member now displays as 'Admin' in the members table and role menu.
Display-layer only — the backend role enum is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:40 -07:00
104 changed files with 7402 additions and 1213 deletions

View File

@@ -22,7 +22,11 @@ defineOptions({
inheritAttrs: false
})
const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
const {
itemClass: itemProp,
contentClass: contentProp,
modal = true
} = defineProps<{
entries?: MenuItem[]
icon?: string
to?: string | HTMLElement
@@ -30,6 +34,7 @@ const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
contentClass?: string
buttonSize?: ButtonVariants['size']
buttonClass?: string
modal?: boolean
}>()
const itemClass = computed(() =>
@@ -58,7 +63,7 @@ const contentStyle = computed(() => {
</script>
<template>
<DropdownMenuRoot v-model:open="open">
<DropdownMenuRoot v-model:open="open" :modal>
<DropdownMenuTrigger as-child>
<slot name="button">
<Button :size="buttonSize ?? 'icon'" :class="buttonClass">

View File

@@ -0,0 +1,40 @@
<template>
<div class="relative mx-2">
<div
class="absolute bottom-6 left-1/2 z-40 flex w-full max-w-78 -translate-x-1/2 items-center gap-2 rounded-lg bg-base-foreground p-2 text-base-background shadow-interface"
>
<Button
v-tooltip.top="{ value: deselectLabel, showDelay: 300 }"
variant="inverted"
size="icon-lg"
type="button"
:aria-label="deselectLabel"
class="rounded-lg hover:bg-base-background/10"
@click="emit('deselect')"
>
<i class="icon-[lucide--x] size-4" />
</Button>
<span class="pr-6 text-sm font-bold whitespace-nowrap tabular-nums">
{{ label }}
</span>
<div class="ml-auto flex shrink-0 items-center gap-1">
<slot />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
defineProps<{
/** The "N selected" text; the caller formats it (pluralization, wording). */
label: string
/** Accessible label + tooltip for the deselect button. */
deselectLabel: string
}>()
const emit = defineEmits<{
deselect: []
}>()
</script>

View File

@@ -14,7 +14,7 @@
class="p-1 text-amber-400"
>
<template #icon>
<i class="icon-[lucide--component]" />
<i class="icon-[lucide--coins]" />
</template>
</Tag>
<div :class="textClass">

View File

@@ -318,6 +318,22 @@ describe('shouldPreventRekaDismiss', () => {
expect(event.defaultPrevented).toBe(false)
})
it('prevents dismiss when clicking a menu trigger to close it', () => {
// A DropdownMenu trigger inside a dialog: clicking it again to close the
// menu must not tear down the surrounding dialog.
const trigger = document.createElement('button')
trigger.setAttribute('aria-haspopup', 'menu')
const icon = document.createElement('i')
trigger.appendChild(icon)
document.body.appendChild(trigger)
const event = makeEvent(icon)
onRekaPointerDownOutside({ dismissableMask: undefined }, event)
expect(event.defaultPrevented).toBe(true)
trigger.remove()
})
it('prevents dismiss when the dialog is not the top-most (stacked)', () => {
// A backgrounded dialog must never dismiss on an outside pointer — the
// pointer belongs to the dialog stacked above it (e.g. Edit Keybinding

View File

@@ -86,7 +86,7 @@
@max-reached="showCeilingWarning = true"
>
<template #prefix>
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
<i class="icon-[lucide--coins] size-4 shrink-0 text-gold-500" />
</template>
</FormattedNumberStepper>
</div>
@@ -98,7 +98,7 @@
v-if="isBelowMin"
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-red-500"
>
<i class="icon-[lucide--component] size-4" />
<i class="icon-[lucide--coins] size-4" />
{{
$t('credits.topUp.minRequired', {
credits: formatNumber(usdToCredits(MIN_AMOUNT))
@@ -109,7 +109,7 @@
v-if="showCeilingWarning"
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-gold-500"
>
<i class="icon-[lucide--component] size-4" />
<i class="icon-[lucide--coins] size-4" />
{{
$t('credits.topUp.maxAllowed', {
credits: formatNumber(usdToCredits(MAX_AMOUNT))

View File

@@ -9,10 +9,11 @@ const PRIMEVUE_OVERLAY_SELECTORS =
// Reka portals its own dialogs / popovers / menus into the body too. When a
// nested Reka layer opens on top of a non-modal parent, the parent's
// DismissableLayer sees the focus shift / pointer-down as "outside" and would
// dismiss itself. These selectors cover the portaled roots so we can treat
// interactions on them as inside.
// dismiss itself. These selectors cover the portaled roots (and the triggers
// that toggle them — clicking a menu/popup trigger to close it must not dismiss
// the surrounding dialog) so we can treat interactions on them as inside.
const REKA_PORTAL_SELECTORS =
'[data-reka-popper-content-wrapper], [data-reka-dialog-content], [data-reka-menu-content], [data-reka-context-menu-content], [role="dialog"], [role="menu"], [role="listbox"], [role="tooltip"]'
'[data-reka-popper-content-wrapper], [data-reka-dialog-content], [data-reka-menu-content], [data-reka-context-menu-content], [role="dialog"], [role="menu"], [role="listbox"], [role="tooltip"], [aria-haspopup="menu"], [aria-haspopup="dialog"], [aria-haspopup="listbox"]'
const OUTSIDE_LAYER_SELECTORS = `${PRIMEVUE_OVERLAY_SELECTORS}, ${REKA_PORTAL_SELECTORS}`

View File

@@ -7,7 +7,7 @@
)
"
>
<i class="icon-[lucide--component] h-full bg-amber-400" />
<i class="icon-[lucide--coins] h-full bg-amber-400" />
<span class="truncate" v-text="text" />
</span>
<span

View File

@@ -51,7 +51,7 @@
>
<i
aria-hidden="true"
class="icon-[lucide--component] size-3 text-amber-400"
class="icon-[lucide--coins] size-3 text-amber-400"
/>
<i
aria-hidden="true"

View File

@@ -31,7 +31,7 @@
<!-- Credits Section -->
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<i class="icon-[lucide--coins] text-sm text-amber-400" />
<Skeleton v-if="isLoading" width="4rem" height="1.25rem" class="w-full" />
<span v-else class="text-base font-semibold text-base-foreground">{{
formattedBalance

View File

@@ -0,0 +1,25 @@
<template>
<CheckboxRoot
v-model="checked"
:class="
cn(
'peer flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-[4px] border border-interface-stroke bg-transparent transition-colors focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:outline-none data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-white',
className
)
"
>
<CheckboxIndicator class="flex items-center justify-center">
<i class="icon-[lucide--check] size-3" />
</CheckboxIndicator>
</CheckboxRoot>
</template>
<script setup lang="ts">
import { CheckboxIndicator, CheckboxRoot } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
const checked = defineModel<boolean>({ default: false })
</script>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import { HoverCardRoot, useForwardPropsEmits } from 'reka-ui'
import type { HoverCardRootEmits, HoverCardRootProps } from 'reka-ui'
import { provide, ref } from 'vue'
import { hoverCardOpenKey } from './hoverCardContext'
// eslint-disable-next-line vue/no-unused-properties -- forwarded to Reka via useForwardPropsEmits
const props = defineProps<HoverCardRootProps>()
const emits = defineEmits<HoverCardRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
const isOpen = ref(false)
provide(hoverCardOpenKey, isOpen)
</script>
<template>
<HoverCardRoot v-bind="forwarded" v-model:open="isOpen">
<slot />
</HoverCardRoot>
</template>

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import { ZIndex } from '@primeuix/utils/zindex'
import { HoverCardContent, HoverCardPortal, useForwardProps } from 'reka-ui'
import type { HoverCardContentProps } from 'reka-ui'
import { computed, inject } from 'vue'
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import { hoverCardOpenKey } from './hoverCardContext'
// Shared base for @primeuix's auto-incrementing 'modal' z-index counter.
const MODAL_BASE_Z_INDEX = 1700
const {
class: className,
side = 'bottom',
sideOffset = 8,
...rest
} = defineProps<HoverCardContentProps & { class?: HTMLAttributes['class'] }>()
const forwarded = useForwardProps(computed(() => rest))
// Body-portaled content sits at a static z-1700 unless a dialog that joined
// @primeuix's 'modal' counter is open above it; then lift past that dialog.
const open = inject(hoverCardOpenKey, undefined)
const contentStyle = computed(() => {
if (!open?.value) return undefined
const topZIndex = ZIndex.getCurrent('modal')
return topZIndex >= MODAL_BASE_Z_INDEX ? { zIndex: topZIndex + 1 } : undefined
})
</script>
<template>
<HoverCardPortal>
<HoverCardContent
v-bind="forwarded"
:side
:side-offset
:style="contentStyle"
:class="
cn(
'z-1700 rounded-lg border border-border-subtle bg-secondary-background p-2.5 shadow-md outline-none',
className
)
"
>
<slot />
</HoverCardContent>
</HoverCardPortal>
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import { HoverCardTrigger } from 'reka-ui'
import type { HoverCardTriggerProps } from 'reka-ui'
const props = defineProps<HoverCardTriggerProps>()
</script>
<template>
<HoverCardTrigger v-bind="props">
<slot />
</HoverCardTrigger>
</template>

View File

@@ -0,0 +1,7 @@
import type { InjectionKey, Ref } from 'vue'
// Shares the root open-state with the content so it can lift its z-index above
// a dialog that joined @primeuix's incrementing 'modal' counter (otherwise the
// body-portaled content renders behind the settings dialog).
export const hoverCardOpenKey: InjectionKey<Ref<boolean>> =
Symbol('hoverCardOpen')

View File

@@ -0,0 +1,72 @@
<template>
<PaginationRoot
:page="page"
:total="total"
:items-per-page="itemsPerPage"
:sibling-count="1"
show-edges
@update:page="(p: number) => emit('update:page', p)"
>
<div class="flex items-center gap-1">
<PaginationPrev as-child>
<Button variant="muted-textonly" size="md" class="text-sm">
<i class="icon-[lucide--chevron-left] size-4" />
{{ $t('g.previous') }}
</Button>
</PaginationPrev>
<PaginationList v-slot="{ items }" class="flex items-center gap-1">
<template v-for="(item, index) in items" :key="index">
<PaginationListItem
v-if="item.type === 'page'"
:value="item.value"
as-child
>
<Button
:variant="item.value === page ? 'secondary' : 'muted-textonly'"
size="icon"
>
{{ item.value }}
</Button>
</PaginationListItem>
<PaginationEllipsis v-else :index="index" :class="ellipsisClass">
</PaginationEllipsis>
</template>
</PaginationList>
<PaginationNext as-child>
<Button variant="muted-textonly" size="md" class="text-sm">
{{ $t('g.next') }}
<i class="icon-[lucide--chevron-right] size-4" />
</Button>
</PaginationNext>
</div>
</PaginationRoot>
</template>
<script setup lang="ts">
import {
PaginationEllipsis,
PaginationList,
PaginationListItem,
PaginationNext,
PaginationPrev,
PaginationRoot
} from 'reka-ui'
import Button from '@/components/ui/button/Button.vue'
const {
page = 1,
total,
itemsPerPage = 10
} = defineProps<{
page?: number
total: number
itemsPerPage?: number
}>()
const emit = defineEmits<{ 'update:page': [page: number] }>()
const ellipsisClass =
'inline-flex size-8 items-center justify-center text-sm text-muted-foreground'
</script>

View File

@@ -35,7 +35,7 @@ export const searchInputSizeConfig = {
icon: 'size-4',
iconPos: 'left-2.5',
inputPl: 'pl-8',
inputText: 'text-xs',
inputText: 'text-sm',
clearPos: 'left-2.5'
},
xl: {

View File

@@ -0,0 +1,30 @@
<template>
<SwitchRoot
v-model="checked"
:disabled
:class="
cn(
'inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent px-0.5 transition-colors focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
checked ? 'bg-primary' : 'bg-interface-stroke'
)
"
>
<SwitchThumb
:class="
cn(
'pointer-events-none block size-4 rounded-full bg-white shadow-sm transition-transform',
checked ? 'translate-x-3.5' : 'translate-x-0'
)
"
/>
</SwitchRoot>
</template>
<script setup lang="ts">
import { SwitchRoot, SwitchThumb } from 'reka-ui'
import { cn } from '@comfyorg/tailwind-utils'
const { disabled = false } = defineProps<{ disabled?: boolean }>()
const checked = defineModel<boolean>({ default: false })
</script>

View File

@@ -0,0 +1,17 @@
<template>
<div :class="cn('relative w-full overflow-auto', className)">
<table
class="w-full caption-bottom border-separate border-spacing-0 text-sm"
>
<slot />
</table>
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>

View File

@@ -0,0 +1,13 @@
<template>
<tbody :class="cn('[&_tr:last-child]:border-0', className)">
<slot />
</tbody>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>

View File

@@ -0,0 +1,13 @@
<template>
<td :class="cn('px-2 py-2.5 align-middle whitespace-nowrap', className)">
<slot />
</td>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>

View File

@@ -0,0 +1,21 @@
<template>
<th
scope="col"
:class="
cn(
'h-10 px-2 text-left align-middle text-sm font-normal whitespace-nowrap text-muted-foreground',
className
)
"
>
<slot />
</th>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>

View File

@@ -0,0 +1,15 @@
<template>
<thead
:class="cn('[&_tr]:border-b [&_tr]:border-interface-stroke/60', className)"
>
<slot />
</thead>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>

View File

@@ -0,0 +1,20 @@
<template>
<tr
:class="
cn(
'border-b border-interface-stroke/60 transition-colors hover:bg-secondary-background/50 data-[state=selected]:bg-secondary-background/50',
className
)
"
>
<slot />
</tr>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import { TabsRoot, useForwardPropsEmits } from 'reka-ui'
import type { TabsRootEmits, TabsRootProps } from 'reka-ui'
// eslint-disable-next-line vue/no-unused-properties -- forwarded to Reka via useForwardPropsEmits
const props = defineProps<TabsRootProps>()
const emits = defineEmits<TabsRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<TabsRoot v-bind="forwarded">
<slot />
</TabsRoot>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import { TabsList } from 'reka-ui'
import type { TabsListProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className, ...rest } = defineProps<
TabsListProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<TabsList
v-bind="rest"
:class="cn('inline-flex items-center gap-4', className)"
>
<slot />
</TabsList>
</template>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import { TabsTrigger, useForwardProps } from 'reka-ui'
import type { TabsTriggerProps } from 'reka-ui'
import { computed } from 'vue'
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className, ...rest } = defineProps<
TabsTriggerProps & { class?: HTMLAttributes['class'] }
>()
const forwarded = useForwardProps(computed(() => rest))
</script>
<template>
<TabsTrigger
v-bind="forwarded"
:class="
cn(
'cursor-pointer appearance-none border-0 border-b-2 border-transparent bg-transparent px-0 pb-2 text-sm text-muted-foreground transition-colors outline-none data-[state=active]:border-base-foreground data-[state=active]:text-base-foreground',
className
)
"
>
<slot />
</TabsTrigger>
</template>

View File

@@ -14,7 +14,12 @@
>
<header
data-component-id="LeftPanelHeader"
class="flex h-18 w-full shrink-0 items-center-safe gap-2 pr-3 pl-6"
:class="
cn(
'flex h-18 w-full shrink-0 items-center-safe gap-2 pr-3 pl-6',
headerHeightClass
)
"
>
<slot name="leftPanelHeaderTitle" />
<Button
@@ -33,7 +38,12 @@
<div class="flex flex-col overflow-hidden bg-base-background">
<header
v-if="$slots.header"
class="flex h-18 w-full items-center justify-between gap-2 px-6"
:class="
cn(
'flex h-18 w-full items-center justify-between gap-2 px-6',
headerHeightClass
)
"
>
<div class="flex min-w-0 flex-1 gap-2">
<Button
@@ -151,20 +161,22 @@ const SIZE_CLASSES = {
} as const
type ModalSize = keyof typeof SIZE_CLASSES
type ContentPadding = 'default' | 'compact' | 'none'
type ContentPadding = 'default' | 'compact' | 'none' | 'flush'
const {
contentTitle,
rightPanelTitle,
size = 'lg',
leftPanelWidth = '14rem',
contentPadding = 'default'
contentPadding = 'default',
headerHeightClass = 'h-18'
} = defineProps<{
contentTitle: string
rightPanelTitle?: string
size?: ModalSize
leftPanelWidth?: string
contentPadding?: ContentPadding
headerHeightClass?: string
}>()
const sizeClasses = computed(() => SIZE_CLASSES[size])
@@ -204,7 +216,10 @@ const contentContainerClass = computed(() =>
cn(
'flex scrollbar-custom min-h-0 flex-1 flex-col overflow-y-auto',
contentPadding === 'default' && 'px-6 pt-0 pb-10',
contentPadding === 'compact' && 'px-6 pt-0 pb-2'
contentPadding === 'compact' && 'px-6 pt-0 pb-2',
// Keep the horizontal inset but let content run to the bottom edge (it
// clips there instead of ending above a padding gap).
contentPadding === 'flush' && 'px-6 pt-0'
)
)

View File

@@ -107,6 +107,8 @@ export interface BillingState {
export interface BillingContext extends BillingState, BillingActions {
type: ComputedRef<BillingType>
/** Subscription paused on a failed payment (`subscriptionStatus === 'paused'`). */
isPaused: ComputedRef<boolean>
/**
* True when the active team workspace is still on a pre-credit-slider
* (legacy) per-member tier plan, which keeps the old team pricing table.

View File

@@ -147,6 +147,7 @@ function useBillingContextInternal(): BillingContext {
const subscriptionStatus = computed(() =>
toValue(activeContext.value.subscriptionStatus)
)
const isPaused = computed(() => subscriptionStatus.value === 'paused')
const tier = computed(() => toValue(activeContext.value.tier))
const renewalDate = computed(() => toValue(activeContext.value.renewalDate))
@@ -301,6 +302,7 @@ function useBillingContextInternal(): BillingContext {
isLegacyTeamPlan,
billingStatus,
subscriptionStatus,
isPaused,
tier,
renewalDate,
getMaxSeats,

View File

@@ -90,7 +90,9 @@ export function useExternalLink() {
githubFrontend: 'https://github.com/Comfy-Org/ComfyUI_frontend',
githubElectron: 'https://github.com/Comfy-Org/electron',
forum: 'https://forum.comfy.org/',
comfyOrg: 'https://www.comfy.org/'
comfyOrg: 'https://www.comfy.org/',
teamPlanRequests:
'https://comfy-org.portal.usepylon.com/forms/team-plan-requests'
}
/** Common doc paths for use with buildDocsUrl */

View File

@@ -1556,6 +1556,8 @@
"3DViewer": "3DViewer",
"Canvas Navigation": "Canvas Navigation",
"PlanCredits": "Plan & Credits",
"Members": "Members",
"PartnerNodes": "Allowlist",
"Vue Nodes": "Nodes 2.0",
"VueNodes": "Nodes 2.0",
"Nodes 2_0": "Nodes 2.0",
@@ -2586,21 +2588,12 @@
"creditsRemainingThisYear": "Included (Refills {date})",
"creditsYouveAdded": "Additional",
"remaining": "remaining",
"refillsDate": "Refills {date}",
"refillsNextCycle": "Refills next cycle",
"creditsUsed": "{used} used",
"creditsLeftOfTotal": "{remaining} left of {total}",
"monthlyUsageProgress": "{used} of {total} monthly credits used",
"percentUsed": "{percent}% used",
"usageProgress": "{used} of {total} credits used",
"additionalCreditsInfo": "About additional credits",
"additionalCredits": "Additional credits",
"additionalCreditsInUse": "In use",
"usedAfterMonthly": "Used after monthly runs out",
"monthlyCreditsUsedUpTitle": "Monthly credits are used up. Refills {date}",
"monthlyCreditsUsedUpTitleNoDate": "Monthly credits are used up",
"monthlyCreditsUsedUpDescription": "You're now spending additional credits.",
"outOfCreditsTitle": "You're out of credits. Credits refill {date}",
"outOfCreditsTitleNoDate": "You're out of credits",
"outOfCreditsDescription": "Add more credits to continue generating.",
"usedAfterMonthly": "Used after plan credits run out",
"additionalCreditsTooltip": "Credits you add on top of your plan. Used after monthly credits run out. Each expires one year after purchase.",
"monthlyCreditsInfo": "These credits refresh monthly and don't roll over",
"viewMoreDetailsPlans": "View more details about plans & pricing",
@@ -2610,6 +2603,7 @@
"planLoadError": "We couldn't load your plan details.",
"planLoadErrorRetry": "Try again",
"teamPlanName": "Team",
"enterprisePlanName": "Enterprise",
"teamPlanIncludes": "Your plan includes everything in {plan}, plus:",
"teamPerks": {
"inviteMembers": "Invite members",
@@ -2845,9 +2839,36 @@
"updatePassword": "Update Password"
},
"workspacePanel": {
"billingStatus": {
"warning": {
"title": "Payment declined",
"body": "Your last payment didn't go through. Your subscription will pause on {date} unless payment is updated."
},
"paused": {
"title": "Subscription paused",
"body": "This workspace's subscription is paused. Update payment to resume.",
"memberBody": "This workspace's subscription is paused. Your workspace admins need to update the payment method."
},
"outOfCredits": {
"title": "Out of credits",
"body": "Your team has used all its credits. Add more credits to continue generating or wait until credits refill on {date}.",
"bodyNoDate": "Your team has used all its credits. Add more credits to continue generating.",
"addCredits": "Add credits",
"dismiss": "Dismiss"
},
"ending": {
"title": "Your team plan ends on {date}",
"body": "Members keep full access until then. Reactivate to keep your shared credits and seats.",
"reactivate": "Reactivate plan"
},
"updatePayment": "Update payment"
},
"charactersLeft": "{count} character left | {count} characters left",
"doubleClickToRename": "Double-click to rename",
"editWorkspaceImage": "Edit workspace image",
"invite": "Invite",
"inviteMember": "Invite member",
"inviteLimitReached": "You've reached the maximum of {count} members",
"inviteLimitReached": "Your workspace is at the member limit",
"tabs": {
"dashboard": "Dashboard",
"planCredits": "Plan & Credits",
@@ -2856,18 +2877,49 @@
"dashboard": {
"placeholder": "Dashboard workspace settings"
},
"partnerNodes": {
"description": "Choose which partner nodes your team can use. Workflows with disabled nodes cannot be run.",
"searchPlaceholder": "Search partner nodes",
"selectAll": "Select all partner nodes",
"columns": {
"name": "Partner Node",
"partner": "Partner",
"lastModified": "Last modified"
},
"neverModified": "—",
"empty": "No partner nodes match your search.",
"selectedCount": "{count} node selected | {count} nodes selected",
"clearSelection": "Clear selection",
"autoEnableLabel": "When a new partner node is added, it should be:",
"autoEnabled": "auto-enabled",
"autoDisabled": "auto-disabled",
"loadError": "Failed to load partner nodes",
"updateError": "Failed to update partner nodes"
},
"members": {
"header": "Members",
"membersCount": "{count} of {maxSeats} members",
"pendingInvitesCount": "{count} pending invite | {count} pending invites",
"tabs": {
"active": "Active",
"membersCount": "Members ({count})",
"pending": "Pending",
"pendingCount": "Pending ({count})"
},
"membersUsage": "{count} of {max} total members.",
"columns": {
"email": "Email",
"inviteDate": "Invite date",
"expiryDate": "Expiry date",
"role": "Role"
"role": "Role",
"lastActivity": "Last activity",
"creditsUsed": "Credits used this month"
},
"activity": {
"justNow": "just now",
"minutesAgo": "{n} min ago",
"hoursAgo": "{n} hr ago",
"daysAgo": "{count} day ago | {count} days ago"
},
"actions": {
"resendInvite": "Resend invite",
@@ -2887,6 +2939,7 @@
},
"menu": {
"editWorkspace": "Edit workspace details",
"renameWorkspace": "Rename Workspace",
"leaveWorkspace": "Leave Workspace",
"deleteWorkspace": "Delete Workspace",
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first",
@@ -2897,6 +2950,113 @@
"nameLabel": "Workspace name",
"save": "Save"
},
"requestMore": "Request more",
"planCredits": {
"tabs": {
"overview": "Credits",
"activity": "Activity",
"invoices": "Invoices"
}
},
"overview": {
"perMonth": "mo",
"renewsOn": "Renews on {date}",
"paused": "Paused",
"managePayment": "Manage payment",
"changePlan": "Change plan",
"usd": "USD",
"nextInvoice": "Next month invoice",
"seeMore": "See more",
"learnMore": "Learn more",
"pricingTable": "Partner Nodes pricing table",
"messageSupport": "Message support",
"snapshot": {
"user": "User",
"lastActivity": "Last activity",
"creditsUsed": "Credits used",
"topSpenders": "Top spenders",
"recentActivity": "Recent activity",
"empty": {
"topSpenders": "No credits used yet this month",
"recentActivity": "No activity yet"
}
},
"inactive": {
"title": "Inactive team subscription",
"subtitle": "Reactivate your team plan to add more members and run workflows",
"reactivate": "Reactivate plan"
}
},
"activity": {
"columns": {
"date": "Date",
"user": "User",
"eventType": "Event type",
"eventDetails": "Event details",
"creditsUsed": "Credits"
},
"hoverCard": {
"totalCreditsUsed": "Total credits used",
"lastActivity": "Last activity",
"partnerNodeUsed": "Partner node used"
},
"fullActivity": "Full activity",
"perUserHint": "Looking for total usage per user?",
"seeMembers": "See Members"
},
"invoices": {
"columns": {
"date": "Date",
"eventType": "Event type",
"price": "Price"
},
"fullHistory": "Full invoice history"
},
"autoReload": {
"title": "Credit auto-reload",
"subtitle": "Automatically add credits when your balance runs low, within an optional monthly budget.",
"enabled": "Enabled",
"disabled": "Disabled",
"edit": "Edit",
"empty": {
"body": "Keep your workflows running with auto-reloaded credits. Set a monthly budget so charges don't surprise you.",
"cta": "Set up auto-reload"
},
"tile": {
"label": "Auto-reload",
"whenBelow": "when credits drop below",
"monthlyBudget": "Monthly budget",
"percentSpent": "{percent}% spent",
"spentOfBudget": "{spent} of {budget}"
},
"badge": {
"paused": "Paused",
"off": "Off"
},
"dialog": {
"title": "Auto-reload credits",
"thresholdLabel": "When credits drop below:",
"amountLabel": "Add this amount of credits:",
"budgetToggleLabel": "Monthly budget",
"budgetToggleHint": "Limit how much is auto-reloaded per month",
"budgetPlaceholderCredits": "Enter an amount of credits",
"budgetPlaceholderUsd": "Enter an amount of dollars",
"minReload": "Minimum amount is {amount}",
"allowsReloads": "Allows {count} reload /mo | Allows {count} reloads /mo",
"credits": "Credits",
"usd": "USD",
"cancel": "Cancel",
"update": "Update"
}
},
"memberLimitDialog": {
"title": "Workspace is at the member limit",
"message": "All seats are filled. To invite someone new, remove a member, rescind an invite, or request more seats."
},
"workflowQueuedDialog": {
"title": "Your workflow is queued",
"message": "Max workflow capacity reached. It'll start automatically as capacity opens up. If this happens often, you can also request for more capacity."
},
"leaveDialog": {
"title": "Leave this workspace?",
"message": "You won't be able to join again unless you contact the workspace owner.",
@@ -2915,12 +3075,12 @@
"error": "Failed to remove member"
},
"changeRoleDialog": {
"promoteTitle": "Make {name} an owner?",
"promoteTitle": "Make {name} an admin?",
"promoteIntro": "They'll be able to:",
"promotePermissionCredits": "Add additional credits",
"promotePermissionManage": "Manage members, payment methods, and workspace settings",
"promotePermissionRoles": "Promote and demote other owners (except the workspace creator).",
"promoteConfirm": "Make owner",
"promotePermissionRoles": "Promote and demote other admins (except the workspace owner).",
"promoteConfirm": "Make admin",
"demoteTitle": "Demote {name} to member?",
"demoteMessage": "They'll lose admin access.",
"demoteConfirm": "Demote to member",
@@ -2991,13 +3151,14 @@
"newWorkspace": "New workspace",
"namePlaceholder": "e.g. Marketing Team",
"createWorkspace": "Create workspace",
"nameValidationError": "Name must be 150 characters using letters, numbers, spaces, or common punctuation."
"nameValidationError": "Name must be 130 characters using letters, numbers, spaces, or common punctuation."
},
"workspaceSwitcher": {
"switchWorkspace": "Switch workspace",
"subscribe": "Subscribe",
"personal": "Personal",
"roleOwner": "Owner",
"roleAdmin": "Admin",
"roleMember": "Member",
"createWorkspace": "Create a workspace",
"maxWorkspacesReached": "You can only own 10 workspaces. Delete one to create a new one.",

View File

@@ -23,6 +23,7 @@ import '@/lib/litegraph/public/css/litegraph.css'
import router from '@/router'
import { isDesktop, isNightly } from '@/platform/distribution/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { installBillingMockHarness } from '@/platform/workspace/dev/billingMockHarness'
import { useBootstrapStore } from '@/stores/bootstrapStore'
import App from './App.vue'
@@ -33,6 +34,10 @@ import { i18n } from './i18n'
const isCloud = __DISTRIBUTION__ === 'cloud'
const hasHostTelemetryBridge = Boolean(window.__comfyDesktop2?.Telemetry)
// PREVIEW HARNESS (do not merge): no-op unless `?billingmock`. Must run before
// /api/features is fetched so the team-workspaces flag merge lands.
installBillingMockHarness()
// Load remote config before initializeApp() below, so getFirebaseConfig() resolves
// against the server's runtime values instead of the build-time defaults.
const { refreshRemoteConfig } =

View File

@@ -1,67 +1,48 @@
<template>
<div class="relative mx-2">
<div
data-testid="assets-selection-bar"
class="absolute bottom-6 left-1/2 z-40 flex w-full max-w-78 -translate-x-1/2 items-center gap-2 rounded-lg bg-base-foreground p-2 text-base-background shadow-interface"
<SelectionBar
data-testid="assets-selection-bar"
:label="$t('mediaAsset.selection.selectedCount', { count })"
:deselect-label="$t('mediaAsset.selection.deselectAll')"
@deselect="emit('deselect')"
>
<Button
v-tooltip.top="{
value: $t('mediaAsset.selection.downloadSelected'),
showDelay: 300
}"
variant="inverted"
size="icon-lg"
type="button"
data-testid="assets-download-selected"
:aria-label="$t('mediaAsset.selection.downloadSelected')"
class="rounded-lg hover:bg-base-background/10"
@click="emit('download')"
>
<i class="icon-[lucide--download] size-4" />
</Button>
<template v-if="showDelete">
<span class="h-6 w-px bg-base-background/20" aria-hidden="true" />
<Button
v-tooltip.top="{
value: $t('mediaAsset.selection.deselectAll'),
value: $t('mediaAsset.selection.deleteSelected'),
showDelay: 300
}"
variant="inverted"
size="icon-lg"
type="button"
data-testid="assets-deselect-selected"
:aria-label="$t('mediaAsset.selection.deselectAll')"
data-testid="assets-delete-selected"
:aria-label="$t('mediaAsset.selection.deleteSelected')"
class="rounded-lg hover:bg-base-background/10"
@click="emit('deselect')"
@click="emit('delete')"
>
<i class="icon-[lucide--x] size-4" />
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<span class="pr-6 text-sm font-bold whitespace-nowrap tabular-nums">
{{ $t('mediaAsset.selection.selectedCount', { count }) }}
</span>
<div class="ml-auto flex shrink-0 items-center gap-1">
<Button
v-tooltip.top="{
value: $t('mediaAsset.selection.downloadSelected'),
showDelay: 300
}"
variant="inverted"
size="icon-lg"
type="button"
data-testid="assets-download-selected"
:aria-label="$t('mediaAsset.selection.downloadSelected')"
class="rounded-lg hover:bg-base-background/10"
@click="emit('download')"
>
<i class="icon-[lucide--download] size-4" />
</Button>
<template v-if="showDelete">
<span class="h-6 w-px bg-base-background/20" aria-hidden="true" />
<Button
v-tooltip.top="{
value: $t('mediaAsset.selection.deleteSelected'),
showDelay: 300
}"
variant="inverted"
size="icon-lg"
type="button"
data-testid="assets-delete-selected"
:aria-label="$t('mediaAsset.selection.deleteSelected')"
class="rounded-lg hover:bg-base-background/10"
@click="emit('delete')"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
</template>
</div>
</div>
</div>
</template>
</SelectionBar>
</template>
<script setup lang="ts">
import SelectionBar from '@/components/common/SelectionBar.vue'
import Button from '@/components/ui/button/Button.vue'
const { count, showDelete = true } = defineProps<{

View File

@@ -53,6 +53,7 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
balance: computed(() => state.balance),
subscription: computed(() => state.subscription),
isPaused: computed(() => false),
isActiveSubscription: computed(() => state.isActiveSubscription),
isFreeTier: computed(() => state.isFreeTier),
currentTeamCreditStop: computed(() => state.currentTeamCreditStop),
@@ -97,24 +98,14 @@ const i18n = createI18n({
remaining: 'remaining',
refreshCredits: 'Refresh credits',
monthly: 'Monthly',
refillsDate: 'Refills {date}',
refillsNextCycle: 'Refills next cycle',
creditsUsed: '{used} used',
creditsLeftOfTotal: '{remaining} left of {total}',
monthlyUsageProgress: '{used} of {total} monthly credits used',
yearly: 'Yearly',
percentUsed: '{percent}% used',
usageProgress: '{used} of {total} credits used',
additionalCreditsInfo: 'About additional credits',
additionalCreditsTooltip: 'Credits you add on top of your plan.',
additionalCredits: 'Additional credits',
additionalCreditsInUse: 'In use',
usedAfterMonthly: 'Used after monthly runs out',
monthlyCreditsUsedUpTitle:
'Monthly credits are used up. Refills {date}',
monthlyCreditsUsedUpTitleNoDate: 'Monthly credits are used up',
monthlyCreditsUsedUpDescription:
"You're now spending additional credits.",
outOfCreditsTitle: "You're out of credits. Credits refill {date}",
outOfCreditsTitleNoDate: "You're out of credits",
outOfCreditsDescription: 'Add more credits to continue generating.',
usedAfterMonthly: 'Used after plan credits run out',
addCredits: 'Add credits',
upgradeToAddCredits: 'Upgrade to add credits'
}
@@ -178,27 +169,19 @@ describe('CreditsTile', () => {
it('renders the monthly usage bar and additional breakdown', () => {
activeProSubscription()
const { container } = renderTile()
// PRO monthly allowance = 21,100; remaining 422 -> used 20,678.
// PRO monthly allowance = 21,100; remaining 422 -> used 20,678 -> 98%.
expect(container.textContent).toContain('Monthly')
expect(container.textContent).toMatch(/Refills Feb/)
expect(container.textContent).toContain('20,678 used')
expect(container.textContent).toContain('422 left of 21,100')
expect(container.textContent).toContain('98% used')
expect(container.textContent).toContain('Additional credits')
expect(container.textContent).toContain('633')
expect(container.textContent).toContain('Used after monthly runs out')
expect(container.textContent).toContain('Used after plan credits run out')
})
it('renders a compact monthly summary for narrow containers', () => {
activeProSubscription()
const { container } = renderTile()
expect(container.textContent).toContain('422 left of 21K')
})
it('uses the team credit stop monthly grant for the monthly total', () => {
it('uses the team credit stop grant for a monthly allowance', () => {
state.isActiveSubscription = true
state.subscription = {
tier: 'TEAM',
duration: 'ANNUAL',
duration: 'MONTHLY',
renewalDate: '2026-02-20T12:00:00Z'
}
state.currentTeamCreditStop = {
@@ -207,13 +190,15 @@ describe('CreditsTile', () => {
stop_usd: 2500
}
state.balance = { amountMicros: 0, cloudCreditBalanceMicros: 200 }
const { container } = renderTile()
// Monthly total is the stop's raw monthly grant, not the tier fallback,
// and is not multiplied by 12 for annual billing.
expect(container.textContent).toContain('422 left of 527,500')
renderTile()
// Allowance is the stop's grant, not the tier fallback.
expect(screen.getByRole('progressbar')).toHaveAttribute(
'aria-valuemax',
'527500'
)
})
it('uses the per-month nominal grant for an annual personal tier', () => {
it('grants the full year upfront for an annual plan', () => {
state.isActiveSubscription = true
state.subscription = {
tier: 'PRO',
@@ -221,35 +206,25 @@ describe('CreditsTile', () => {
renewalDate: '2026-02-20T12:00:00Z'
}
state.balance = { amountMicros: 0, cloudCreditBalanceMicros: 200 }
const { container } = renderTile()
// Annual billing still grants the monthly nominal (21,100), not 12x.
expect(container.textContent).toContain('422 left of 21,100')
expect(container.textContent).not.toContain('253,200')
renderTile()
// Annual plans grant the whole year at once: 21,100 x 12.
expect(screen.getByRole('progressbar')).toHaveAttribute(
'aria-valuemax',
'253200'
)
})
it('falls back to a dateless refills label when renewal date is missing', () => {
activeProSubscription()
state.subscription = { tier: 'PRO', duration: 'MONTHLY', renewalDate: null }
const { container } = renderTile()
expect(container.textContent).toContain('Refills next cycle')
expect(container.textContent).not.toContain('Refills Feb')
})
it('uses a dateless out-of-credits notice when renewal date is invalid', () => {
activeProSubscription()
it('labels the allowance by billing duration (yearly for annual)', () => {
state.isActiveSubscription = true
state.subscription = {
tier: 'PRO',
duration: 'MONTHLY',
renewalDate: 'not-a-date'
duration: 'ANNUAL',
renewalDate: '2026-02-20T12:00:00Z'
}
state.balance = {
amountMicros: 0,
cloudCreditBalanceMicros: 0,
prepaidBalanceMicros: 0
}
const { container } = renderTile()
expect(container.textContent).toContain("You're out of credits")
expect(container.textContent).not.toContain('Credits refill')
state.balance = { amountMicros: 0, cloudCreditBalanceMicros: 200 }
renderTile()
expect(screen.getByText('Yearly')).toBeInTheDocument()
expect(screen.queryByText('Monthly')).not.toBeInTheDocument()
})
it('hides the breakdown and forces zeros in the zero state', () => {
@@ -271,11 +246,9 @@ describe('CreditsTile', () => {
expect(screen.queryByText('Add credits')).toBeNull()
})
it('shows no depletion notice or in-use badge while monthly credits remain', () => {
it('shows no in-use badge while monthly credits remain', () => {
activeProSubscription()
const { container } = renderTile()
expect(container.textContent).not.toContain('Monthly credits are used up')
expect(container.textContent).not.toContain("You're out of credits")
renderTile()
expect(screen.queryByText('In use')).toBeNull()
})
@@ -286,42 +259,29 @@ describe('CreditsTile', () => {
cloudCreditBalanceMicros: 0,
prepaidBalanceMicros: 300
}
const { container } = renderTile()
expect(container.textContent).toContain(
'Monthly credits are used up. Refills Feb 20'
)
expect(container.textContent).toContain(
"You're now spending additional credits."
)
renderTile()
expect(screen.getByText('In use')).toBeTruthy()
expect(screen.getByText('Add credits').dataset.variant).toBe('secondary')
expect(screen.getByText('Add credits').dataset.variant).toBe('tertiary')
})
it('emphasizes add-credits when fully out of credits', () => {
it('emphasizes add-credits when fully out of credits, without a punch-out notice', () => {
activeProSubscription()
state.balance = {
amountMicros: 0,
cloudCreditBalanceMicros: 0,
prepaidBalanceMicros: 0
}
const { container } = renderTile()
expect(container.textContent).toContain(
"You're out of credits. Credits refill Feb 20"
)
expect(container.textContent).toContain(
'Add more credits to continue generating.'
)
renderTile()
expect(screen.queryByText('In use')).toBeNull()
expect(screen.getByText('Add credits').dataset.variant).toBe('inverted')
})
it('suppresses the depletion notice until the balance has loaded', () => {
it('shows no in-use badge until the balance has loaded', () => {
activeProSubscription()
state.balance = null
state.isLoading = true
const { container } = renderTile()
expect(container.textContent).not.toContain('Monthly credits are used up')
expect(container.textContent).not.toContain("You're out of credits")
renderTile()
expect(screen.queryByText('In use')).toBeNull()
})
it('routes add-credits through telemetry + the top-up dialog', async () => {

View File

@@ -1,6 +1,15 @@
<template>
<div
class="@container relative flex flex-col gap-6 rounded-2xl border border-interface-stroke bg-modal-panel-background px-6 py-5"
:class="
cn(
'@container relative flex flex-col gap-6 rounded-2xl border border-interface-stroke bg-modal-panel-background px-6 py-5 transition-opacity',
// Paused subscriptions can't spend credits, so dim the whole tile to
// read as frozen and defer to the Update-payment banner. A lapsed plan
// (frozen) reads the same way.
(isPaused || frozen) && 'opacity-50',
customClass
)
"
>
<Button
variant="muted-textonly"
@@ -19,8 +28,10 @@
</div>
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
<div v-else class="flex items-baseline gap-2">
<i class="icon-[lucide--component] size-4 self-center text-credit" />
<span class="text-2xl leading-none font-bold">{{ displayTotal }}</span>
<i class="icon-[lucide--coins] size-4 self-center text-credit" />
<span class="text-2xl leading-none font-bold tabular-nums">{{
displayTotal
}}</span>
<span class="text-sm text-muted @max-[300px]:hidden">{{
$t('subscription.remaining')
}}</span>
@@ -28,37 +39,22 @@
</div>
<template v-if="showBreakdown">
<div
v-if="emptyStateNotice"
class="flex items-start gap-2 rounded-lg bg-base-background p-3 text-sm"
>
<i
class="mt-0.5 icon-[lucide--info] size-4 shrink-0 text-base-foreground"
/>
<div class="flex flex-col gap-1">
<span class="text-base-foreground">{{ emptyStateNotice.title }}</span>
<span class="text-muted">{{ emptyStateNotice.description }}</span>
</div>
</div>
<div
v-if="showBar"
:class="cn('flex flex-col gap-2', isMonthlyDepleted && 'opacity-30')"
:class="cn('flex flex-col gap-2', isAllowanceDepleted && 'opacity-30')"
>
<div class="flex items-center justify-between text-sm">
<span class="text-text-primary">{{
$t('subscription.monthly')
}}</span>
<span class="text-muted">{{ cycleLabel }}</span>
<span class="text-muted">
{{ refillsLabel }}
{{ cycleStatusLabel }}
</span>
</div>
<div
role="progressbar"
:aria-valuenow="usage.used"
:aria-valuemin="0"
:aria-valuemax="monthlyTotalCredits ?? 0"
:aria-valuetext="monthlyUsageLabel"
:aria-valuemax="allowanceTotalCredits ?? 0"
:aria-valuetext="cycleUsageLabel"
class="h-2 w-full overflow-hidden rounded-full bg-secondary-background-hover"
>
<div
@@ -66,40 +62,6 @@
:style="{ width: usedBarWidth }"
/>
</div>
<div class="flex items-center justify-between gap-2 text-sm">
<Skeleton
v-if="isLoadingBalance"
class="@max-[300px]:hidden"
width="5rem"
height="1rem"
/>
<span v-else class="text-muted @max-[300px]:hidden">
{{ $t('subscription.creditsUsed', { used: usedDisplay }) }}
</span>
<Skeleton v-if="isLoadingBalance" width="9rem" height="1rem" />
<span
v-else
class="flex items-center gap-1 font-bold text-text-primary"
>
<i class="icon-[lucide--component] size-4 text-credit" />
<span class="@max-[180px]:hidden">
{{
$t('subscription.creditsLeftOfTotal', {
remaining: monthlyBonusCredits,
total: monthlyTotalDisplay
})
}}
</span>
<span class="hidden @max-[180px]:inline">
{{
$t('subscription.creditsLeftOfTotal', {
remaining: monthlyRemainingCompact,
total: monthlyTotalCompact
})
}}
</span>
</span>
</div>
</div>
<div class="h-px w-full bg-interface-stroke" />
@@ -118,7 +80,7 @@
variant="muted-textonly"
size="icon-sm"
:aria-label="$t('subscription.additionalCreditsInfo')"
class="text-muted"
class="flex cursor-help appearance-none items-center border-none bg-transparent p-0 text-muted transition-colors hover:text-text-primary"
>
<i class="icon-[lucide--info] size-4" />
</Button>
@@ -132,9 +94,9 @@
<Skeleton v-if="isLoadingBalance" width="3rem" height="1rem" />
<span
v-else
class="flex items-center gap-1 font-bold text-text-primary"
class="flex items-center gap-1 font-bold text-text-primary tabular-nums"
>
<i class="icon-[lucide--component] size-4 text-credit" />
<i class="icon-[lucide--coins] size-4 text-credit" />
{{ displayPrepaid }}
</span>
</div>
@@ -156,15 +118,10 @@
</Button>
<Button
v-else
:variant="isOutOfCredits ? 'inverted' : 'secondary'"
:variant="isOutOfCredits ? 'inverted' : 'tertiary'"
size="lg"
:class="
cn(
'w-full font-normal',
!isOutOfCredits &&
'bg-interface-menu-component-surface-selected text-text-primary'
)
"
class="w-full font-normal"
:disabled="isPaused || frozen"
@click="handleAddCredits"
>
{{ $t('subscription.addCredits') }}
@@ -178,6 +135,7 @@ import { cn } from '@comfyorg/tailwind-utils'
import { useEventListener } from '@vueuse/core'
import Skeleton from 'primevue/skeleton'
import { computed, onMounted } from 'vue'
import type { HTMLAttributes } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCredits } from '@/base/credits/comfyCredits'
@@ -186,40 +144,45 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import {
DEFAULT_TIER_KEY,
TIER_TO_KEY,
getTierCredits
} from '@/platform/cloud/subscription/constants/tierPricing'
import { computeMonthlyUsage } from '@/platform/cloud/subscription/utils/creditsProgress'
import { useTelemetry } from '@/platform/telemetry'
import { consumePendingTopup } from '@/platform/telemetry/topupTracker'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useDialogService } from '@/services/dialogService'
const { zeroState = false } = defineProps<{
const {
zeroState = false,
frozen = false,
class: customClass
} = defineProps<{
/** Forces the zero-credit display (e.g. unsubscribed / member view). */
zeroState?: boolean
/**
* Renders the full breakdown but dimmed and non-interactive, for a lapsed
* subscription that still has a shape to show. Mirrors the paused treatment.
*/
frozen?: boolean
class?: HTMLAttributes['class']
}>()
const { locale, t } = useI18n()
const {
subscription,
isPaused,
balance,
isActiveSubscription,
isFreeTier,
currentTeamCreditStop,
fetchBalance,
fetchStatus
} = useBillingContext()
const {
monthlyBonusCredits,
prepaidCredits,
totalCredits,
monthlyBonusCreditsValue,
prepaidCreditsValue,
isLoadingBalance
isLoadingBalance,
allowanceTotalCredits,
usage
} = useSubscriptionCredits()
const { permissions } = useWorkspaceUI()
const { showPricingTable } = useSubscriptionDialog()
@@ -227,40 +190,18 @@ const { wrapWithErrorHandlingAsync } = useErrorHandling()
const dialogService = useDialogService()
const telemetry = useTelemetry()
const tierKey = computed(() => {
const tier = subscription.value?.tier
if (!tier) return DEFAULT_TIER_KEY
return TIER_TO_KEY[tier] ?? DEFAULT_TIER_KEY
})
const monthlyTotalCredits = computed<number | null>(() => {
const teamStop = currentTeamCreditStop.value
if (teamStop) return teamStop.credits_monthly
return getTierCredits(tierKey.value)
})
const usage = computed(() =>
computeMonthlyUsage(
monthlyBonusCreditsValue.value,
monthlyTotalCredits.value ?? 0
)
const cycleLabel = computed(() =>
subscription.value?.duration === 'ANNUAL'
? t('subscription.yearly')
: t('subscription.monthly')
)
const refillsDateShort = computed(() => {
const raw = subscription.value?.renewalDate
if (!raw) return ''
const date = new Date(raw)
return Number.isNaN(date.getTime())
? ''
: date.toLocaleDateString(locale.value, { month: 'short', day: 'numeric' })
})
const cycleUsedPercent = computed(() =>
Math.round(usage.value.usedFraction * 100)
)
const hasRefillsDate = computed(() => refillsDateShort.value !== '')
const refillsLabel = computed(() =>
hasRefillsDate.value
? t('subscription.refillsDate', { date: refillsDateShort.value })
: t('subscription.refillsNextCycle')
const cycleStatusLabel = computed(() =>
t('subscription.percentUsed', { percent: cycleUsedPercent.value })
)
const formatCreditCount = (value: number) =>
@@ -270,82 +211,58 @@ const formatCreditCount = (value: number) =>
numberOptions: { maximumFractionDigits: 0 }
})
const monthlyTotalDisplay = computed(() => {
const total = monthlyTotalCredits.value
const allowanceTotalDisplay = computed(() => {
const total = allowanceTotalCredits.value
return total === null ? '—' : formatCreditCount(total)
})
const usedDisplay = computed(() => formatCreditCount(usage.value.used))
const compactNumber = computed(
() => new Intl.NumberFormat(locale.value, { notation: 'compact' })
)
const monthlyRemainingCompact = computed(() =>
compactNumber.value.format(monthlyBonusCreditsValue.value)
)
const monthlyTotalCompact = computed(() => {
const total = monthlyTotalCredits.value
return total === null ? '—' : compactNumber.value.format(total)
})
const displayTotal = computed(() => (zeroState ? '0' : totalCredits.value))
const displayPrepaid = computed(() => (zeroState ? '0' : prepaidCredits.value))
const usedBarWidth = computed(
() => `${(usage.value.usedFraction * 100).toFixed(2)}%`
)
const monthlyUsageLabel = computed(() =>
t('subscription.monthlyUsageProgress', {
const cycleUsageLabel = computed(() =>
t('subscription.usageProgress', {
used: usedDisplay.value,
total: monthlyTotalDisplay.value
total: allowanceTotalDisplay.value
})
)
const showBreakdown = computed(() => isActiveSubscription.value && !zeroState)
const showBreakdown = computed(
() => (isActiveSubscription.value || frozen) && !zeroState
)
const showBar = computed(
() =>
showBreakdown.value &&
monthlyTotalCredits.value !== null &&
monthlyTotalCredits.value > 0
allowanceTotalCredits.value !== null &&
allowanceTotalCredits.value > 0
)
const showActionButton = computed(
() => isActiveSubscription.value && !zeroState && permissions.value.canTopUp
() =>
(isActiveSubscription.value || frozen) &&
!zeroState &&
permissions.value.canTopUp
)
const isMonthlyDepleted = computed(
const isAllowanceDepleted = computed(
() =>
!isPaused.value &&
!frozen &&
showBar.value &&
!isLoadingBalance.value &&
balance.value != null &&
monthlyBonusCreditsValue.value <= 0
)
const isOutOfCredits = computed(
() => isMonthlyDepleted.value && prepaidCreditsValue.value <= 0
)
const isSpendingAdditional = computed(
() => isMonthlyDepleted.value && prepaidCreditsValue.value > 0
() => isAllowanceDepleted.value && prepaidCreditsValue.value > 0
)
// Fully out (monthly depleted and no additional credits left): emphasize the
// add-credits button. Spending-additional keeps the quieter tertiary.
const isOutOfCredits = computed(
() => isAllowanceDepleted.value && prepaidCreditsValue.value <= 0
)
const emptyStateNotice = computed(() => {
if (isOutOfCredits.value) {
return {
title: hasRefillsDate.value
? t('subscription.outOfCreditsTitle', { date: refillsDateShort.value })
: t('subscription.outOfCreditsTitleNoDate'),
description: t('subscription.outOfCreditsDescription')
}
}
if (isMonthlyDepleted.value) {
return {
title: hasRefillsDate.value
? t('subscription.monthlyCreditsUsedUpTitle', {
date: refillsDateShort.value
})
: t('subscription.monthlyCreditsUsedUpTitleNoDate'),
description: t('subscription.monthlyCreditsUsedUpDescription')
}
}
return null
})
const handleRefresh = wrapWithErrorHandlingAsync(async () => {
await Promise.all([fetchBalance(), fetchStatus()])

View File

@@ -6,6 +6,12 @@ import {
formatCreditsFromCents
} from '@/base/credits/comfyCredits'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import {
DEFAULT_TIER_KEY,
TIER_TO_KEY,
getTierCredits
} from '@/platform/cloud/subscription/constants/tierPricing'
import { computeMonthlyUsage } from '@/platform/cloud/subscription/utils/creditsProgress'
/**
* Composable for handling subscription credit calculations and formatting.
@@ -64,12 +70,44 @@ export function useSubscriptionCredits() {
creditsFromMicros(toValue(billingContext.balance)?.prepaidBalanceMicros)
)
// Total credits granted for the current billing cycle. Team plans read the
// credit stop; personal tiers read the tier grant. Annual plans front-load the
// whole year, so multiply the monthly nominal by the cycle length.
const cycleMonths = computed(() =>
toValue(billingContext.subscription)?.duration === 'ANNUAL' ? 12 : 1
)
const allowanceTotalCredits = computed<number | null>(() => {
const teamStop = toValue(billingContext.currentTeamCreditStop)
const tier = toValue(billingContext.subscription)?.tier
const tierKey = tier
? (TIER_TO_KEY[tier] ?? DEFAULT_TIER_KEY)
: DEFAULT_TIER_KEY
const monthly = teamStop
? teamStop.credits_monthly
: getTierCredits(tierKey)
return monthly === null ? null : monthly * cycleMonths.value
})
// Usage of that allowance drives the credits bar. Paused plans read as unused
// (credits are frozen), so force it to zero.
const usage = computed(() => {
const base = computeMonthlyUsage(
monthlyBonusCreditsValue.value,
allowanceTotalCredits.value ?? 0
)
return toValue(billingContext.isPaused)
? { ...base, used: 0, usedFraction: 0 }
: base
})
return {
totalCredits,
monthlyBonusCredits,
prepaidCredits,
monthlyBonusCreditsValue,
prepaidCreditsValue,
isLoadingBalance
isLoadingBalance,
allowanceTotalCredits,
usage
}
}

View File

@@ -1,5 +1,11 @@
<template>
<BaseModalLayout content-title="" data-testid="settings-dialog" size="full">
<BaseModalLayout
content-title=""
data-testid="settings-dialog"
size="full"
header-height-class="h-22"
:content-padding="isWorkspacePanel ? 'flush' : 'default'"
>
<template #leftPanelHeaderTitle>
<i class="icon-[lucide--settings]" />
<h2 class="text-neutral text-base">{{ $t('g.settings') }}</h2>
@@ -48,6 +54,7 @@
id="keybinding-panel-header"
class="flex-1"
/>
<WorkspaceSettingsHeader v-else-if="isWorkspacePanel" />
</template>
<template #header-right-area>
@@ -55,6 +62,7 @@
v-if="activeCategoryKey === 'keybinding'"
id="keybinding-panel-actions"
/>
<WorkspaceMenuButton v-else-if="isWorkspacePanel" />
</template>
<template #content>
@@ -93,8 +101,11 @@ import NavTitle from '@/components/widget/nav/NavTitle.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import ColorPaletteMessage from '@/platform/settings/components/ColorPaletteMessage.vue'
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
import WorkspaceMenuButton from '@/platform/workspace/components/dialogs/settings/WorkspaceMenuButton.vue'
import WorkspaceSettingsHeader from '@/platform/workspace/components/dialogs/settings/WorkspaceSettingsHeader.vue'
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
import { useSettingUI } from '@/platform/settings/composables/useSettingUI'
import { useSettingsNavigation } from '@/platform/settings/composables/useSettingsNavigation'
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
import type { SettingTreeNode } from '@/platform/settings/settingStore'
import type {
@@ -135,6 +146,14 @@ const { fetchBalance } = useBillingContext()
const navRef = ref<HTMLElement | null>(null)
const activeCategoryKey = ref<string | null>(defaultCategory.value?.key ?? null)
// Let panels deep-link into a sibling panel (e.g. Overview → Members).
const { requestedPanelKey } = useSettingsNavigation()
watch(requestedPanelKey, (key) => {
if (!key) return
activeCategoryKey.value = key
requestedPanelKey.value = null
})
const searchableNavItems = computed(() =>
navGroups.value.flatMap((g) =>
g.items.map((item) => ({
@@ -172,6 +191,17 @@ const activePanel = computed(() => {
return findPanelByKey(activeCategoryKey.value)
})
const WORKSPACE_PANEL_KEYS = [
'workspace',
'workspace-members',
'workspace-partner-nodes'
]
const isWorkspacePanel = computed(
() =>
!!activeCategoryKey.value &&
WORKSPACE_PANEL_KEYS.includes(activeCategoryKey.value)
)
const getGroupSortOrder = (group: SettingTreeNode): number =>
Math.max(0, ...flattenTree<SettingParams>(group).map((s) => s.sortOrder ?? 0))

View File

@@ -19,7 +19,8 @@ const env = vi.hoisted(() => {
teamWorkspacesEnabled: false,
userSecretsEnabled: false,
isActiveSubscription: false,
billingType: 'legacy' as 'legacy' | 'workspace'
billingType: 'legacy' as 'legacy' | 'workspace',
canManagePartnerNodes: false
}
const fakeRef = <K extends keyof typeof state>(key: K) => ({
get value() {
@@ -75,6 +76,16 @@ vi.mock('@/platform/settings/settingStore', () => ({
getSettingInfo: vi.fn()
}))
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
permissions: {
get value() {
return { canManagePartnerNodes: env.state.canManagePartnerNodes }
}
}
})
}))
interface MockSettingParams {
id: string
name: string
@@ -116,7 +127,8 @@ describe('useSettingUI', () => {
teamWorkspacesEnabled: false,
userSecretsEnabled: false,
isActiveSubscription: false,
billingType: 'legacy'
billingType: 'legacy',
canManagePartnerNodes: false
})
vi.mocked(useSettingStore).mockReturnValue({
@@ -233,5 +245,24 @@ describe('useSettingUI', () => {
}
}
})
it('splits the workspace panel into plan and members sidebar entries', () => {
const { navGroups } = useSettingUI()
const keys = navKeys(navGroups.value)
expect(keys).toContain('workspace')
expect(keys).toContain('workspace-members')
})
it('shows the partner nodes entry only to owners and admins', () => {
env.state.canManagePartnerNodes = false
expect(navKeys(useSettingUI().navGroups.value)).not.toContain(
'workspace-partner-nodes'
)
env.state.canManagePartnerNodes = true
expect(navKeys(useSettingUI().navGroups.value)).toContain(
'workspace-partner-nodes'
)
})
})
})

View File

@@ -13,6 +13,7 @@ import {
} from '@/platform/settings/settingStore'
import type { SettingTreeNode } from '@/platform/settings/settingStore'
import type { SettingPanelType, SettingParams } from '@/platform/settings/types'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import type { NavGroupData } from '@/types/navTypes'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { buildTree } from '@/utils/treeUtil'
@@ -27,13 +28,15 @@ const CATEGORY_ICONS: Record<string, string> = {
keybinding: 'icon-[lucide--keyboard]',
LiteGraph: 'icon-[lucide--workflow]',
'Mask Editor': 'icon-[lucide--pen-tool]',
Members: 'icon-[lucide--users]',
Other: 'icon-[lucide--ellipsis]',
PlanCredits: 'icon-[lucide--credit-card]',
PartnerNodes: 'icon-[lucide--shield-check]',
PlanCredits: 'icon-[lucide--receipt-text]',
secrets: 'icon-[lucide--key-round]',
'server-config': 'icon-[lucide--server]',
subscription: 'icon-[lucide--credit-card]',
user: 'icon-[lucide--user]',
workspace: 'icon-[lucide--building-2]'
workspace: 'icon-[lucide--receipt-text]'
}
interface SettingPanelItem {
@@ -54,6 +57,7 @@ export function useSettingUI(
const { flags } = useFeatureFlags()
const { shouldRenderVueNodes } = useVueFeatureFlags()
const { isActiveSubscription, type: billingType } = useBillingContext()
const { permissions } = useWorkspaceUI()
const teamWorkspacesEnabled = computed(
() => isCloud && flags.teamWorkspacesEnabled
@@ -175,16 +179,42 @@ export function useSettingUI(
)
}
// Workspace panel: only available on cloud with team workspaces enabled
const workspacePanel: SettingPanelItem = {
// Workspace panels: only available on cloud with team workspaces enabled.
// The old single "Workspace" panel is split into three sidebar entries; the
// Plan & Credits entry keeps the 'workspace' key so existing deep links land.
const planCreditsPanel: SettingPanelItem = {
node: {
key: 'workspace',
label: 'Workspace',
label: 'PlanCredits',
children: []
},
component: defineAsyncComponent(
() =>
import('@/platform/workspace/components/dialogs/settings/WorkspacePanelContent.vue')
import('@/platform/workspace/components/dialogs/settings/PlanCreditsPanelContent.vue')
)
}
const membersPanel: SettingPanelItem = {
node: {
key: 'workspace-members',
label: 'Members',
children: []
},
component: defineAsyncComponent(
() =>
import('@/platform/workspace/components/dialogs/settings/WorkspaceMembersPanelContent.vue')
)
}
const partnerNodesPanel: SettingPanelItem = {
node: {
key: 'workspace-partner-nodes',
label: 'PartnerNodes',
children: []
},
component: defineAsyncComponent(
() =>
import('@/platform/workspace/components/dialogs/settings/PartnerNodesPanelContent.vue')
)
}
@@ -192,6 +222,12 @@ export function useSettingUI(
() => teamWorkspacesEnabled.value && isLoggedIn.value
)
// Partner-node governance is Owner/Admin-only; Members never see the tab.
const shouldShowPartnerNodesPanel = computed(
() =>
shouldShowWorkspacePanel.value && permissions.value.canManagePartnerNodes
)
const secretsPanel: SettingPanelItem = {
node: {
key: 'secrets',
@@ -245,7 +281,10 @@ export function useSettingUI(
aboutPanel,
creditsPanel,
userPanel,
...(shouldShowWorkspacePanel.value ? [workspacePanel] : []),
...(shouldShowWorkspacePanel.value
? [planCreditsPanel, membersPanel]
: []),
...(shouldShowPartnerNodesPanel.value ? [partnerNodesPanel] : []),
keybindingPanel,
extensionPanel,
...(isDesktop ? [serverConfigPanel] : []),
@@ -295,8 +334,14 @@ export function useSettingUI(
key: 'workspace',
label: 'Workspace',
children: [
...(shouldShowWorkspacePanel.value ? [workspacePanel.node] : []),
...(isLoggedIn.value &&
...(shouldShowWorkspacePanel.value
? [planCreditsPanel.node, membersPanel.node]
: []),
...(shouldShowPartnerNodesPanel.value ? [partnerNodesPanel.node] : []),
// The legacy per-account Credits panel is redundant once the workspace
// Plan & Credits panel is present, which now owns the credit balance.
...(!shouldShowWorkspacePanel.value &&
isLoggedIn.value &&
!(isCloud && window.__CONFIG__?.subscription_required)
? [creditsPanel.node]
: [])

View File

@@ -0,0 +1,13 @@
import { ref } from 'vue'
// A one-shot request to switch the open Settings dialog to another panel, so a
// panel's content can deep-link into a sibling panel (e.g. Overview → Members).
const requestedPanelKey = ref<string | null>(null)
export function useSettingsNavigation() {
function navigateToPanel(key: string) {
requestedPanelKey.value = key
}
return { requestedPanelKey, navigateToPanel }
}

View File

@@ -87,3 +87,5 @@ export type SettingPanelType =
| 'subscription'
| 'user'
| 'workspace'
| 'workspace-members'
| 'workspace-partner-nodes'

View File

@@ -0,0 +1,89 @@
import axios from 'axios'
import { attachUnifiedRemintInterceptor } from '@/platform/auth/unified/remintRetry'
import { api } from '@/scripts/api'
import { useAuthStore } from '@/stores/authStore'
/** A partner (paid-API) node the workspace can allow or block. */
export interface PartnerNode {
id: string
name: string
partner: string
/** ISO date of the last governance change, or null if never modified. */
last_modified: string | null
enabled: boolean
}
export interface PartnerNodesResponse {
partner_nodes: PartnerNode[]
/** Workspace default applied to newly added partner nodes. */
auto_enable_new: boolean
}
interface SetEnabledPayload {
enabled: boolean
}
interface BulkSetEnabledPayload {
node_ids: string[]
enabled: boolean
}
interface SetAutoEnablePayload {
auto_enable_new: boolean
}
const partnerNodesApiClient = axios.create({
timeout: 10000,
headers: { 'Content-Type': 'application/json' }
})
attachUnifiedRemintInterceptor(partnerNodesApiClient)
async function authHeader() {
return useAuthStore().getAuthHeaderOrThrow()
}
export const partnerNodesApi = {
/** GET /api/workspace/partner-nodes */
async list(): Promise<PartnerNodesResponse> {
const headers = await authHeader()
const response = await partnerNodesApiClient.get<PartnerNodesResponse>(
api.apiURL('/workspace/partner-nodes'),
{ headers }
)
return response.data
},
/** PATCH /api/workspace/partner-nodes/:id */
async setEnabled(nodeId: string, enabled: boolean): Promise<void> {
const headers = await authHeader()
const payload: SetEnabledPayload = { enabled }
await partnerNodesApiClient.patch(
api.apiURL(`/workspace/partner-nodes/${nodeId}`),
payload,
{ headers }
)
},
/** PATCH /api/workspace/partner-nodes (bulk) */
async setEnabledBulk(nodeIds: string[], enabled: boolean): Promise<void> {
const headers = await authHeader()
const payload: BulkSetEnabledPayload = { node_ids: nodeIds, enabled }
await partnerNodesApiClient.patch(
api.apiURL('/workspace/partner-nodes'),
payload,
{ headers }
)
},
/** PUT /api/workspace/partner-nodes/settings */
async setAutoEnableNew(autoEnableNew: boolean): Promise<void> {
const headers = await authHeader()
const payload: SetAutoEnablePayload = { auto_enable_new: autoEnableNew }
await partnerNodesApiClient.put(
api.apiURL('/workspace/partner-nodes/settings'),
payload,
{ headers }
)
}
}

View File

@@ -37,6 +37,11 @@ export interface Member {
// billing lifecycle actions (cancel / reactivate / downgrade).
// Optional: the cloud OpenAPI does not carry this field yet.
is_original_owner?: boolean
// Last time the member ran or interacted with the workspace, and the credits
// they've consumed in the current billing cycle. Optional: the cloud OpenAPI
// does not carry these fields yet.
last_active_at?: string | null
credits_used_this_month?: number
}
interface PaginationInfo {
@@ -244,6 +249,7 @@ export type BillingSubscriptionStatus =
| 'scheduled'
| 'ended'
| 'canceled'
| 'paused'
export type BillingStatus =
| 'awaiting_payment_method'

View File

@@ -59,7 +59,7 @@
<!-- Credits Section -->
<div class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<i class="icon-[lucide--coins] text-sm text-amber-400" />
<Skeleton
v-if="isLoadingBalance"
width="4rem"

View File

@@ -129,7 +129,7 @@
{{ t('subscription.monthlyCreditsPerMemberLabel') }}
</span>
<div class="flex flex-row items-center gap-1">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<i class="icon-[lucide--coins] text-sm text-amber-400" />
<span
class="font-inter text-sm/normal font-bold text-base-foreground"
>

View File

@@ -3,7 +3,7 @@
<!-- Loading state while subscription is being set up -->
<div
v-if="isSettingUp"
class="rounded-2xl border border-interface-stroke p-6"
class="rounded-2xl border border-interface-stroke/60 p-6"
>
<div class="flex items-center gap-2 py-4 text-muted-foreground">
<i class="pi pi-spin pi-spinner" />
@@ -14,7 +14,7 @@
<!-- Billing data still loading: avoid rendering a false Free/$0 plan -->
<div
v-else-if="isLoading && !subscription"
class="rounded-2xl border border-interface-stroke p-6"
class="rounded-2xl border border-interface-stroke/60 p-6"
>
<div class="flex items-center gap-2 py-4 text-muted-foreground">
<i class="pi pi-spin pi-spinner" />
@@ -25,7 +25,7 @@
<!-- Billing fetch failed: offer retry rather than a misleading Free plan -->
<div
v-else-if="error && !subscription"
class="flex flex-col items-start gap-3 rounded-2xl border border-interface-stroke p-6"
class="flex flex-col items-start gap-3 rounded-2xl border border-interface-stroke/60 p-6"
>
<div class="flex items-center gap-2 text-text-secondary">
<i class="pi pi-exclamation-circle text-danger" />
@@ -67,7 +67,7 @@
</div>
</div>
<div class="rounded-2xl border border-interface-stroke p-6">
<div class="rounded-2xl border border-interface-stroke/60 p-6">
<div>
<div
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between md:gap-2"
@@ -439,11 +439,13 @@ const subscriptionTierName = computed(() => {
: baseName
})
const planDisplayName = computed(() =>
isInPersonalWorkspace.value
? subscriptionTierName.value
const planDisplayName = computed(() => {
if (isInPersonalWorkspace.value) return subscriptionTierName.value
// 'ENTERPRISE' is a wire tier not yet in the generated SubscriptionTier union.
return (subscription.value?.tier as string | null) === 'ENTERPRISE'
? t('subscription.enterprisePlanName')
: t('subscription.teamPlanName')
)
})
const tierKey = computed(() => {
const tier = subscription.value?.tier

View File

@@ -86,7 +86,7 @@
@max-reached="showCeilingWarning = true"
>
<template #prefix>
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
<i class="icon-[lucide--coins] size-4 shrink-0 text-gold-500" />
</template>
</FormattedNumberStepper>
</div>
@@ -98,7 +98,7 @@
v-if="isBelowMin"
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-red-500"
>
<i class="icon-[lucide--component] size-4" />
<i class="icon-[lucide--coins] size-4" />
{{
$t('credits.topUp.minRequired', {
credits: formatNumber(usdToCredits(MIN_AMOUNT))
@@ -109,7 +109,7 @@
v-if="showCeilingWarning"
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-gold-500"
>
<i class="icon-[lucide--component] size-4" />
<i class="icon-[lucide--coins] size-4" />
{{
$t('credits.topUp.maxAllowed', {
credits: formatNumber(usdToCredits(MAX_AMOUNT))

View File

@@ -1,22 +1,37 @@
<template>
<div
class="flex aspect-square size-8 items-center justify-center rounded-md text-base font-semibold text-white"
:style="{
background: gradient,
textShadow: '0 1px 2px rgba(0, 0, 0, 0.2)'
}"
:class="
cn(
'flex aspect-square size-8 items-center justify-center overflow-hidden rounded-md text-base font-semibold text-white',
$attrs.class as string
)
"
:style="imageUrl ? undefined : { background: gradient, textShadow }"
>
{{ letter }}
<img
v-if="imageUrl"
:src="imageUrl"
:alt="workspaceName"
class="size-full object-cover"
/>
<template v-else>{{ letter }}</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const { workspaceName } = defineProps<{
import { cn } from '@comfyorg/tailwind-utils'
defineOptions({ inheritAttrs: false })
const { workspaceName, imageUrl } = defineProps<{
workspaceName: string
imageUrl?: string
}>()
const textShadow = '0 1px 2px rgba(0, 0, 0, 0.2)'
const letter = computed(() => workspaceName?.charAt(0)?.toUpperCase() ?? '?')
const gradient = computed(() => {

View File

@@ -0,0 +1,313 @@
<template>
<div
class="flex w-132 max-w-full flex-col rounded-2xl border border-border-default bg-base-background"
>
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h2 class="m-0 text-sm font-normal text-base-foreground">
{{ $t('workspacePanel.autoReload.dialog.title') }}
</h2>
<button
class="cursor-pointer rounded-sm border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground"
:aria-label="$t('g.close')"
@click="onClose"
>
<i class="pi pi-times size-4" />
</button>
</div>
<div class="flex flex-col gap-4 p-4">
<div class="flex flex-col gap-2">
<label
for="auto-reload-threshold"
class="text-sm text-muted-foreground"
>
{{ $t('workspacePanel.autoReload.dialog.thresholdLabel') }}
</label>
<div :class="fieldClass">
<i class="icon-[lucide--coins] size-4 shrink-0 text-credit" />
<input
id="auto-reload-threshold"
v-model="thresholdModel"
inputmode="numeric"
class="w-full min-w-0 border-none bg-transparent text-sm text-base-foreground tabular-nums outline-none"
/>
</div>
</div>
<div class="flex flex-col gap-2">
<label for="auto-reload-amount" class="text-sm text-muted-foreground">
{{ $t('workspacePanel.autoReload.dialog.amountLabel') }}
</label>
<div
:class="cn(fieldClass, reloadBelowMinimum && 'ring-1 ring-red-500')"
>
<i
v-if="unit === 'credits'"
class="icon-[lucide--coins] size-4 shrink-0 text-credit"
/>
<span v-else class="shrink-0 text-sm text-muted-foreground">$</span>
<input
id="auto-reload-amount"
v-model="reloadModel"
inputmode="numeric"
class="w-full min-w-0 border-none bg-transparent text-sm text-base-foreground tabular-nums outline-none"
/>
<span
class="flex shrink-0 items-center gap-1 text-sm text-muted-foreground tabular-nums"
>
<template v-if="unit === 'credits'"
> {{ reloadCostLabel }}</template
>
<template v-else>
<i class="icon-[lucide--coins] size-3.5 text-muted-foreground" />
{{ reloadCreditsLabel }}
</template>
</span>
</div>
<p v-if="reloadError" class="m-0 text-xs text-red-500">
{{ reloadError }}
</p>
</div>
</div>
<div class="flex flex-col gap-2 border-t border-border-default p-4">
<div class="flex items-center justify-between">
<span
id="auto-reload-budget-label"
class="text-sm font-medium text-base-foreground"
>
{{ $t('workspacePanel.autoReload.dialog.budgetToggleLabel') }}
</span>
<span class="flex items-center gap-2 text-sm text-muted-foreground">
{{
budgetEnabled
? $t('workspacePanel.autoReload.enabled')
: $t('workspacePanel.autoReload.disabled')
}}
<Switch v-model="budgetEnabled" />
</span>
</div>
<p class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.autoReload.dialog.budgetToggleHint') }}
</p>
<div :class="cn(fieldClass, !budgetEnabled && 'opacity-50')">
<i
v-if="unit === 'credits'"
class="icon-[lucide--coins] size-4 shrink-0 text-credit"
/>
<span v-else class="shrink-0 text-sm text-muted-foreground">$</span>
<input
v-model="budgetModel"
:disabled="!budgetEnabled"
aria-labelledby="auto-reload-budget-label"
inputmode="numeric"
:placeholder="budgetPlaceholder"
class="w-full min-w-0 border-none bg-transparent text-sm text-base-foreground tabular-nums outline-none disabled:cursor-not-allowed"
/>
<span
v-if="budgetEnabled && budgetCents > 0"
class="flex shrink-0 items-center gap-1 text-sm text-muted-foreground tabular-nums"
>
<template v-if="unit === 'credits'"> {{ budgetUsdLabel }}</template>
<template v-else>
<i class="icon-[lucide--coins] size-3.5 text-muted-foreground" />
{{ budgetCreditsLabel }}
</template>
</span>
</div>
<p
v-if="budgetEnabled && budgetCents > 0"
class="m-0 text-xs text-muted-foreground"
>
{{ allowsReloadsLabel }}
</p>
</div>
<div
class="flex items-center justify-between border-t border-border-default p-4"
>
<ToggleGroup
type="single"
:model-value="unit"
class="rounded-lg bg-secondary-background p-0.5"
@update:model-value="onUnitChange"
>
<ToggleGroupItem
v-for="option in unitOptions"
:key="option"
:value="option"
size="lg"
>
{{ $t(`workspacePanel.autoReload.dialog.${option}`) }}
</ToggleGroupItem>
</ToggleGroup>
<div class="flex items-center gap-4">
<Button variant="muted-textonly" @click="onClose">
{{ $t('workspacePanel.autoReload.dialog.cancel') }}
</Button>
<Button
variant="secondary"
size="lg"
:disabled="!canUpdate"
@click="onUpdate"
>
{{ $t('workspacePanel.autoReload.dialog.update') }}
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
centsToCredits,
creditsToCents,
creditsToUsd,
usdToCents,
usdToCredits
} from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import Switch from '@/components/ui/switch/Switch.vue'
import ToggleGroup from '@/components/ui/toggle-group/ToggleGroup.vue'
import ToggleGroupItem from '@/components/ui/toggle-group/ToggleGroupItem.vue'
import { useAutoReload } from '@/platform/workspace/composables/useAutoReload'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@comfyorg/tailwind-utils'
const { t, n: fmtNumber } = useI18n()
const dialogStore = useDialogStore()
const { config, save } = useAutoReload()
type Unit = 'credits' | 'usd'
const unitOptions: Unit[] = ['credits', 'usd']
const unit = ref<Unit>('credits')
// reka's single ToggleGroup can emit '' on re-click (deselect); ignore that so a
// unit always stays selected.
function onUnitChange(value: unknown) {
if (value === 'credits' || value === 'usd') unit.value = value
}
const thresholdCredits = ref(config.thresholdCredits ?? 1000)
const reloadCredits = ref(config.reloadCredits ?? 5000)
const budgetEnabled = ref(config.monthlyBudgetCents != null)
const budgetCents = ref(config.monthlyBudgetCents ?? 0)
const fieldClass =
'flex items-center gap-2 rounded-lg bg-secondary-background px-3 py-2.5'
const fmtInt = (value: number) => fmtNumber(value, { maximumFractionDigits: 0 })
const fmtUsd = (cents: number) =>
fmtNumber(cents / 100, {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0
})
const parseNum = (raw: string) => {
const parsed = Number(raw.replace(/[^0-9.]/g, ''))
return Number.isFinite(parsed) ? parsed : 0
}
// A 0 value renders as an empty field (not "0") so backspacing clears it.
const thresholdModel = computed({
get: () =>
thresholdCredits.value === 0 ? '' : fmtInt(thresholdCredits.value),
set: (value) => (thresholdCredits.value = Math.round(parseNum(value)))
})
const reloadModel = computed({
get: () => {
if (reloadCredits.value === 0) return ''
return unit.value === 'credits'
? fmtInt(reloadCredits.value)
: fmtInt(creditsToUsd(reloadCredits.value))
},
set: (value) => {
const parsed = parseNum(value)
reloadCredits.value =
unit.value === 'credits' ? Math.round(parsed) : usdToCredits(parsed)
}
})
const reloadCostLabel = computed(() =>
fmtUsd(creditsToCents(reloadCredits.value))
)
const reloadCreditsLabel = computed(() => fmtInt(reloadCredits.value))
const budgetModel = computed({
get: () => {
if (budgetCents.value === 0) return ''
return unit.value === 'credits'
? fmtInt(centsToCredits(budgetCents.value))
: fmtInt(Math.round(budgetCents.value / 100))
},
set: (value) => {
const parsed = parseNum(value)
budgetCents.value =
unit.value === 'credits'
? creditsToCents(Math.round(parsed))
: usdToCents(parsed)
}
})
const budgetUsdLabel = computed(() => fmtUsd(budgetCents.value))
const budgetCreditsLabel = computed(() =>
fmtInt(centsToCredits(budgetCents.value))
)
const budgetPlaceholder = computed(() =>
unit.value === 'credits'
? t('workspacePanel.autoReload.dialog.budgetPlaceholderCredits')
: t('workspacePanel.autoReload.dialog.budgetPlaceholderUsd')
)
const allowsReloadsLabel = computed(() => {
const reloads =
reloadCredits.value > 0
? Math.floor(centsToCredits(budgetCents.value) / reloadCredits.value)
: 0
return t('workspacePanel.autoReload.dialog.allowsReloads', reloads)
})
// The reload amount must be worth at least $5 (its credit equivalent).
const MIN_RELOAD_CENTS = 500
const MIN_RELOAD_CREDITS = usdToCredits(5)
const reloadBelowMinimum = computed(
() => reloadCredits.value > 0 && reloadCredits.value < MIN_RELOAD_CREDITS
)
const reloadError = computed(() => {
if (!reloadBelowMinimum.value) return ''
const amount =
unit.value === 'credits'
? fmtInt(MIN_RELOAD_CREDITS)
: fmtUsd(MIN_RELOAD_CENTS)
return t('workspacePanel.autoReload.dialog.minReload', { amount })
})
const canUpdate = computed(
() =>
thresholdCredits.value > 0 &&
reloadCredits.value >= MIN_RELOAD_CREDITS &&
(!budgetEnabled.value || budgetCents.value > 0)
)
function onClose() {
dialogStore.closeDialog({ key: 'auto-reload' })
}
function onUpdate() {
if (!canUpdate.value) return
save({
thresholdCredits: thresholdCredits.value,
reloadCredits: reloadCredits.value,
monthlyBudgetCents:
budgetEnabled.value && budgetCents.value > 0 ? budgetCents.value : null
})
onClose()
}
</script>

View File

@@ -56,7 +56,7 @@ describe('ChangeMemberRoleDialogContent', () => {
mockChangeMemberRole.mockResolvedValue(undefined)
})
it('shows promote copy and confirms with Make owner', async () => {
it('shows promote copy and confirms with Make admin', async () => {
const { user } = renderDialog('owner')
expect(

View File

@@ -64,6 +64,7 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { WORKSPACE_NAME_MAX_LENGTH } from '@/platform/workspace/workspaceConstants'
import { useDialogStore } from '@/stores/dialogStore'
const { onConfirm } = defineProps<{
@@ -80,7 +81,11 @@ const workspaceName = ref('')
const isValidName = computed(() => {
const name = workspaceName.value.trim()
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_'.,()&+]*$/
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
return (
name.length >= 1 &&
name.length <= WORKSPACE_NAME_MAX_LENGTH &&
safeNameRegex.test(name)
)
})
function onCancel() {

View File

@@ -58,6 +58,7 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { WORKSPACE_NAME_MAX_LENGTH } from '@/platform/workspace/workspaceConstants'
import { useDialogStore } from '@/stores/dialogStore'
const { t } = useI18n()
@@ -70,7 +71,11 @@ const newWorkspaceName = ref(workspaceStore.workspaceName)
const isValidName = computed(() => {
const name = newWorkspaceName.value.trim()
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_'.,()&+]*$/
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
return (
name.length >= 1 &&
name.length <= WORKSPACE_NAME_MAX_LENGTH &&
safeNameRegex.test(name)
)
})
function onCancel() {

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex w-full max-w-lg flex-col rounded-2xl border border-border-default bg-base-background"
class="flex w-132 max-w-full flex-col rounded-2xl border border-border-default bg-base-background"
>
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
@@ -35,7 +35,7 @@
:value="email"
:class="
cn(
'rounded-full',
'rounded-full bg-tertiary-background-hover',
!EMAIL_REGEX.test(email) && 'bg-danger/20 text-danger'
)
"

View File

@@ -0,0 +1,54 @@
<template>
<div
class="flex w-full max-w-[400px] flex-col rounded-2xl border border-border-default bg-base-background"
>
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h2 class="m-0 text-sm font-normal text-base-foreground">{{ title }}</h2>
<button
class="focus-visible:ring-secondary-foreground cursor-pointer rounded-sm border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:ring-1 focus-visible:outline-none"
:aria-label="$t('g.close')"
@click="close"
>
<i class="pi pi-times size-4" />
</button>
</div>
<div class="p-4">
<p class="m-0 text-sm text-muted-foreground">{{ message }}</p>
</div>
<div class="flex items-center justify-end gap-2 p-4">
<Button variant="muted-textonly" @click="close">
{{ $t('g.close') }}
</Button>
<Button variant="secondary" size="lg" @click="requestMore">
{{ $t('workspacePanel.requestMore') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import { useDialogStore } from '@/stores/dialogStore'
const { dialogKey, onRequestMore } = defineProps<{
dialogKey: string
title: string
message: string
onRequestMore: () => void
}>()
const dialogStore = useDialogStore()
function close() {
dialogStore.closeDialog({ key: dialogKey })
}
function requestMore() {
onRequestMore()
close()
}
</script>

View File

@@ -232,7 +232,7 @@ describe('TeamWorkspacesDialogContent', () => {
expect(findCreateButton(container)).toBeDisabled()
})
it('disables create button for name exceeding 50 characters', async () => {
it('disables create button for name exceeding the character limit', async () => {
const { container, user } = mountComponent()
const input = container.querySelector(
'#workspace-name-input'

View File

@@ -145,6 +145,7 @@ import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfil
import { useWorkspaceSwitch } from '@/platform/workspace/composables/useWorkspaceSwitch'
import { useWorkspaceTierLabel } from '@/platform/workspace/composables/useWorkspaceTierLabel'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { WORKSPACE_NAME_MAX_LENGTH } from '@/platform/workspace/workspaceConstants'
import { useDialogStore } from '@/stores/dialogStore'
const { onConfirm } = defineProps<{
@@ -178,7 +179,11 @@ const tierLabels = computed(
const isValidName = computed(() => {
const name = workspaceName.value.trim()
return name.length >= 1 && name.length <= 50 && SAFE_NAME_REGEX.test(name)
return (
name.length >= 1 &&
name.length <= WORKSPACE_NAME_MAX_LENGTH &&
SAFE_NAME_REGEX.test(name)
)
})
function onCancel() {

View File

@@ -0,0 +1,205 @@
<template>
<div
:class="
cn(
'flex flex-col gap-4 rounded-2xl border border-interface-stroke/60 p-6 transition-opacity',
// A lapsed plan can't auto-reload, so freeze the whole section: dim it,
// block interaction, and force the toggle to read Disabled.
frozen && 'pointer-events-none opacity-50'
)
"
>
<div
class="flex flex-col gap-4 @4xl:flex-row @4xl:items-start @4xl:justify-between"
>
<div class="flex flex-col gap-1">
<span class="text-sm font-medium text-base-foreground">
{{ $t('workspacePanel.autoReload.title') }}
</span>
<span class="max-w-md text-sm text-muted-foreground">
{{ $t('workspacePanel.autoReload.subtitle') }}
</span>
</div>
<div v-if="isConfigured" class="flex shrink-0 items-center gap-3">
<span class="flex items-center gap-2 text-sm text-muted-foreground">
{{ enabledLabel }}
<Switch
:model-value="displayEnabled"
@update:model-value="setEnabled"
/>
</span>
<Button variant="secondary" size="lg" @click="openConfig">
{{ $t('workspacePanel.autoReload.edit') }}
</Button>
</div>
</div>
<!-- Empty / not-set-up state same one-column grid as the configured tile
so both sit at the top tiles' width. -->
<div v-if="!isConfigured" class="grid grid-cols-1 gap-4 @4xl:grid-cols-2">
<div
class="flex flex-col gap-3 rounded-xl bg-modal-panel-background px-6 py-5"
>
<p class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.autoReload.empty.body') }}
</p>
<Button variant="tertiary" size="lg" class="w-full" @click="openConfig">
{{ $t('workspacePanel.autoReload.empty.cta') }}
</Button>
</div>
</div>
<!-- Configured tile constrained to one column of the same grid the top
tiles use, so it matches their width (empty second column left open). -->
<div v-else class="grid grid-cols-1 gap-4 @4xl:grid-cols-2">
<div
:class="
cn(
'flex flex-col gap-4 rounded-xl bg-modal-panel-background px-6 py-5 transition-opacity',
// Skip this dim when frozen — the section root already dims uniformly.
!frozen && !isEnabled && 'opacity-50'
)
"
>
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">
{{ $t('workspacePanel.autoReload.tile.label') }}
</span>
<StatusBadge v-if="badge" :label="badge" :severity="badgeSeverity" />
</div>
<p
:class="
cn(
'm-0 flex items-center gap-1.5 text-sm text-muted-foreground',
isPaused && 'opacity-50'
)
"
>
<i class="icon-[lucide--coins] size-4 text-credit" />
<span
class="text-2xl leading-none font-semibold text-base-foreground tabular-nums"
>
{{ reloadCreditsLabel }}
</span>
{{ $t('workspacePanel.autoReload.tile.whenBelow') }}
<span class="font-semibold text-base-foreground tabular-nums">
{{ thresholdLabel }}
</span>
</p>
<template v-if="hasBudget">
<div class="h-px w-full bg-interface-stroke" />
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">
{{ $t('workspacePanel.autoReload.tile.monthlyBudget') }}
</span>
<span :class="cn('tabular-nums', percentSpentClass)">
{{ percentSpentLabel }}
</span>
</div>
<ProgressBar :value="budgetUsedFraction" />
<div class="flex justify-end text-sm">
<span class="text-muted-foreground tabular-nums">
{{ budgetSpentLabel }}
</span>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import StatusBadge from '@/components/common/StatusBadge.vue'
import Button from '@/components/ui/button/Button.vue'
import Switch from '@/components/ui/switch/Switch.vue'
import ProgressBar from '@/platform/workspace/components/dialogs/settings/ProgressBar.vue'
import { useAutoReload } from '@/platform/workspace/composables/useAutoReload'
import { useDialogService } from '@/services/dialogService'
import { cn } from '@comfyorg/tailwind-utils'
const { frozen = false } = defineProps<{
/**
* The plan can't spend (lapsed or paused): render the whole section dimmed,
* off, and non-interactive.
*/
frozen?: boolean
}>()
const { t, n: fmtNumber } = useI18n()
const {
config,
isConfigured,
isEnabled,
hasBudget,
budgetUsedFraction,
isPaused,
isWarning,
setEnabled
} = useAutoReload()
const displayEnabled = computed(() => !frozen && isEnabled.value)
const { showAutoReloadDialog } = useDialogService()
function openConfig() {
void showAutoReloadDialog()
}
const fmtCredits = (value: number) =>
fmtNumber(value, { maximumFractionDigits: 0 })
const fmtUsd = (cents: number) =>
fmtNumber(cents / 100, {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0
})
const enabledLabel = computed(() =>
displayEnabled.value
? t('workspacePanel.autoReload.enabled')
: t('workspacePanel.autoReload.disabled')
)
const badge = computed(() => {
if (frozen) return t('workspacePanel.autoReload.badge.off')
if (isPaused.value) return t('workspacePanel.autoReload.badge.paused')
if (!isEnabled.value) return t('workspacePanel.autoReload.badge.off')
return ''
})
// Paused is an alert state, so give it the high-contrast (inverted) pill; the
// quieter "off" state keeps the secondary treatment.
const badgeSeverity = computed(() =>
isPaused.value ? 'contrast' : 'secondary'
)
const reloadCreditsLabel = computed(() => fmtCredits(config.reloadCredits))
const thresholdLabel = computed(() => fmtCredits(config.thresholdCredits))
const percentSpentLabel = computed(() =>
t('workspacePanel.autoReload.tile.percentSpent', {
percent: Math.round(budgetUsedFraction.value * 100)
})
)
const percentSpentClass = computed(() =>
isPaused.value
? 'text-danger'
: isWarning.value
? 'text-credit'
: 'text-muted-foreground'
)
const budgetSpentLabel = computed(() =>
t('workspacePanel.autoReload.tile.spentOfBudget', {
spent: fmtUsd(config.spentThisCycleCents),
budget: fmtUsd(config.monthlyBudgetCents ?? 0)
})
)
</script>

View File

@@ -0,0 +1,174 @@
<template>
<div
v-if="banner"
role="status"
class="flex flex-col gap-3 rounded-2xl border border-interface-stroke/60 bg-base-background p-4 @2xl:flex-row @2xl:items-center @2xl:gap-2"
>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<div class="flex items-center gap-2">
<i
:class="
cn(
'size-4 shrink-0',
// Muted circle for the calm plan-ending notice; amber triangle for
// every action-needed problem (paused, payment failed, out of credits).
banner.kind === 'ending'
? 'icon-[lucide--circle-alert] text-muted-foreground'
: 'icon-[lucide--triangle-alert] text-warning-background'
)
"
/>
<span class="text-sm text-base-foreground">{{ banner.title }}</span>
</div>
<p class="m-0 pl-6 text-sm text-muted-foreground">{{ banner.body }}</p>
</div>
<div
v-if="banner.showAction"
class="flex shrink-0 flex-wrap items-center gap-2 pl-6 @2xl:pl-0"
>
<slot name="actions" />
<template v-if="banner.kind === 'outOfCredits'">
<Button variant="textonly" size="lg" @click="dismiss">
{{ $t('workspacePanel.billingStatus.outOfCredits.dismiss') }}
</Button>
<Button variant="secondary" size="lg" @click="handleAddCredits">
{{ $t('workspacePanel.billingStatus.outOfCredits.addCredits') }}
</Button>
</template>
<Button
v-else-if="banner.kind === 'ending'"
variant="secondary"
size="lg"
:loading="isResubscribing"
@click="handleResubscribe"
>
{{ $t('workspacePanel.billingStatus.ending.reactivate') }}
</Button>
<Button v-else variant="inverted" size="lg">
{{ $t('workspacePanel.billingStatus.updatePayment') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useResubscribe } from '@/platform/workspace/composables/useResubscribe'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useDialogService } from '@/services/dialogService'
import { cn } from '@comfyorg/tailwind-utils'
const { t, d } = useI18n()
const {
billingStatus,
isPaused,
isActiveSubscription,
subscription,
renewalDate
} = useBillingContext()
const { permissions, isInPersonalWorkspace } = useWorkspaceUI()
const dialogService = useDialogService()
const { isResubscribing, handleResubscribe } = useResubscribe()
const canManage = computed(() => permissions.value.canManageSubscription)
const cycleResetDate = computed(() => {
const raw = renewalDate.value
return raw ? d(new Date(raw), { month: 'short', day: 'numeric' }) : ''
})
const planEndDate = computed(() => {
const raw = subscription.value?.endDate
return raw
? d(new Date(raw), { year: 'numeric', month: 'long', day: 'numeric' })
: ''
})
// Out of credits: an active, non-paused team that has exhausted its balance.
// Paused takes over this slot (see priority below). Dismissible for the session.
const dismissed = ref(false)
const isOutOfCredits = computed(
() =>
isActiveSubscription.value &&
!isPaused.value &&
subscription.value?.hasFunds === false
)
// A cancelled-but-still-active plan is winding down to its end date. Unlike the
// states above it's a calm, owner-initiated notice (not a problem), so it sits
// last and reads with the muted circle icon and a low-key secondary action.
const isEnding = computed(
() =>
isActiveSubscription.value &&
!isPaused.value &&
(subscription.value?.isCancelled ?? false) &&
planEndDate.value !== ''
)
// One status banner slot across every workspace tab, in priority order: paused →
// payment-failure warning → out of credits → plan ending. All owner/admin-only
// (members can't act on any of them).
const banner = computed(() => {
if (isInPersonalWorkspace.value) return null
if (isPaused.value) {
return {
kind: 'paused' as const,
title: t('workspacePanel.billingStatus.paused.title'),
body: canManage.value
? t('workspacePanel.billingStatus.paused.body')
: t('workspacePanel.billingStatus.paused.memberBody'),
showAction: canManage.value
}
}
if (billingStatus.value === 'payment_failed' && canManage.value) {
return {
kind: 'warning' as const,
title: t('workspacePanel.billingStatus.warning.title'),
body: t('workspacePanel.billingStatus.warning.body', {
date: cycleResetDate.value
}),
showAction: true
}
}
if (isOutOfCredits.value && canManage.value && !dismissed.value) {
return {
kind: 'outOfCredits' as const,
title: t('workspacePanel.billingStatus.outOfCredits.title'),
body: cycleResetDate.value
? t('workspacePanel.billingStatus.outOfCredits.body', {
date: cycleResetDate.value
})
: t('workspacePanel.billingStatus.outOfCredits.bodyNoDate'),
showAction: true
}
}
if (isEnding.value && canManage.value) {
return {
kind: 'ending' as const,
title: t('workspacePanel.billingStatus.ending.title', {
date: planEndDate.value
}),
body: t('workspacePanel.billingStatus.ending.body'),
showAction: true
}
}
return null
})
function dismiss() {
dismissed.value = true
}
function handleAddCredits() {
void dialogService.showTopUpCreditsDialog()
}
</script>

View File

@@ -1,91 +0,0 @@
<template>
<div
:data-testid="`member-row-${member.id}`"
:class="
cn(
'grid w-full items-center rounded-lg p-2',
isSingleSeatPlan ? 'grid-cols-1' : gridCols,
striped && 'bg-secondary-background/50'
)
"
>
<div class="flex items-center gap-3">
<UserAvatar
class="size-8"
:photo-url="isCurrentUser ? photoUrl : undefined"
:pt:icon:class="{ 'text-xl!': !isCurrentUser || !photoUrl }"
/>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<span class="text-sm text-base-foreground">
{{ member.name }}
<span v-if="isCurrentUser" class="text-muted-foreground">
({{ $t('g.you') }})
</span>
</span>
<span class="text-sm text-muted-foreground">
{{ member.email }}
</span>
</div>
</div>
<span
v-if="showRoleColumn && !isSingleSeatPlan"
class="text-right text-sm text-muted-foreground"
>
{{
member.role === 'owner'
? $t('workspaceSwitcher.roleOwner')
: $t('workspaceSwitcher.roleMember')
}}
</span>
<div
v-if="canManageMembers && !isSingleSeatPlan"
class="flex items-center justify-end"
>
<DropdownMenu
v-if="!isCurrentUser && !isOriginalOwner"
:entries="menuItems"
>
<template #button>
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="muted-textonly"
size="icon"
:aria-label="$t('g.moreOptions')"
>
<i class="pi pi-ellipsis-h" />
</Button>
</template>
</DropdownMenu>
</div>
</div>
</template>
<script setup lang="ts">
import type { MenuItem } from 'primevue/menuitem'
import DropdownMenu from '@/components/common/DropdownMenu.vue'
import UserAvatar from '@/components/common/UserAvatar.vue'
import Button from '@/components/ui/button/Button.vue'
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@comfyorg/tailwind-utils'
const {
showRoleColumn = false,
canManageMembers = false,
isSingleSeatPlan = false,
isOriginalOwner = false,
striped = false,
menuItems = []
} = defineProps<{
member: WorkspaceMember
isCurrentUser: boolean
photoUrl?: string
gridCols: string
showRoleColumn?: boolean
canManageMembers?: boolean
isSingleSeatPlan?: boolean
isOriginalOwner?: boolean
striped?: boolean
menuItems?: MenuItem[]
}>()
</script>

View File

@@ -0,0 +1,117 @@
<template>
<TableRow
:data-testid="`member-row-${member.id}`"
class="group hover:bg-transparent [&:last-child>td]:border-b-0 [&>td]:border-b [&>td]:border-interface-stroke/20"
>
<TableCell>
<div class="flex items-center gap-3">
<span
class="flex size-8 shrink-0 items-center justify-center rounded-full"
:style="{
backgroundColor: userBadgeColor(member.name || member.email)
}"
>
<span class="text-sm font-bold text-base-foreground">
{{ initial }}
</span>
</span>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<span class="text-sm text-base-foreground">
{{ member.name }}
<span v-if="isCurrentUser" class="text-muted-foreground">
({{ $t('g.you') }})
</span>
</span>
<span class="truncate text-sm text-muted-foreground">
{{ member.email }}
</span>
</div>
</div>
</TableCell>
<TableCell class="text-sm text-muted-foreground">
{{ $t(roleLabelKey(member.role, isOriginalOwner)) }}
</TableCell>
<TableCell v-if="canManageMembers" class="text-sm text-muted-foreground">
{{ lastActivityLabel }}
</TableCell>
<TableCell
v-if="canManageMembers"
class="text-right text-sm text-muted-foreground tabular-nums"
>
{{ creditsLabel }}
</TableCell>
<TableCell v-if="canManageMembers" class="text-right" @click.stop>
<DropdownMenu
v-if="showMenu"
:entries="menuItems"
:modal="false"
content-class="min-w-44"
>
<template #button>
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="muted-textonly"
size="icon"
:aria-label="$t('g.moreOptions')"
>
<i class="pi pi-ellipsis-h" />
</Button>
</template>
</DropdownMenu>
</TableCell>
</TableRow>
</template>
<script setup lang="ts">
import type { MenuItem } from 'primevue/menuitem'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import DropdownMenu from '@/components/common/DropdownMenu.vue'
import Button from '@/components/ui/button/Button.vue'
import TableCell from '@/components/ui/table/TableCell.vue'
import TableRow from '@/components/ui/table/TableRow.vue'
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
import { userBadgeColor } from '@/platform/workspace/utils/badgeColor'
import { roleLabelKey } from '@/platform/workspace/utils/roleLabels'
import { formatRelativeTime } from '@/platform/workspace/utils/relativeTime'
const {
member,
isCurrentUser,
canManageMembers = false,
isOriginalOwner = false,
menuItems = []
} = defineProps<{
member: WorkspaceMember
isCurrentUser: boolean
canManageMembers?: boolean
isOriginalOwner?: boolean
menuItems?: MenuItem[]
}>()
const { t } = useI18n()
const initial = computed(() =>
(member.name || member.email).charAt(0).toUpperCase()
)
// The creator and the current user can't be managed from their own row.
const showMenu = computed(
() => canManageMembers && !isCurrentUser && !isOriginalOwner
)
const lastActivityLabel = computed(() => {
if (!member.lastActivity) return '—'
return formatRelativeTime(member.lastActivity, new Date(), {
justNow: t('workspacePanel.members.activity.justNow'),
minutesAgo: (n) => t('workspacePanel.members.activity.minutesAgo', { n }),
hoursAgo: (n) => t('workspacePanel.members.activity.hoursAgo', { n }),
daysAgo: (n) => t('workspacePanel.members.activity.daysAgo', n)
})
})
const creditsLabel = computed(() =>
(member.creditsUsedThisMonth ?? 0).toLocaleString()
)
</script>

View File

@@ -1,8 +1,7 @@
import { render, screen, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Slots } from 'vue'
import { computed, h, ref } from 'vue'
import { computed, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import MembersPanelContent from './MembersPanelContent.vue'
@@ -18,6 +17,7 @@ const mockMemberMenuItems = vi.fn(() => [])
const mockShowTeamPlans = vi.fn()
const mockToggleSort = vi.fn()
const mockHandleInviteMember = vi.fn()
const mockFetchBalance = vi.fn()
const {
mockMembers,
@@ -27,13 +27,14 @@ const {
mockFilteredPendingInvites,
mockIsPersonalWorkspace,
mockIsOnTeamPlan,
mockHasMultipleMembers,
mockShowSearch,
mockShowViewTabs,
mockShowInviteButton,
mockIsInviteDisabled,
mockActiveView,
mockSearchQuery,
mockSortField,
mockSortDirection,
mockPermissions,
mockUiConfig
} = vi.hoisted(() => {
@@ -44,7 +45,6 @@ const {
mockMembers: ref<WorkspaceMember[]>([]),
mockPendingInvites: ref<PendingInvite[]>([]),
mockOriginalOwnerId: ref<string | null>(null),
mockHasMultipleMembers: ref(true),
mockShowSearch: ref(true),
mockShowViewTabs: ref(true),
mockShowInviteButton: ref(true),
@@ -55,39 +55,36 @@ const {
mockIsOnTeamPlan: ref(true),
mockActiveView: ref<'active' | 'pending'>('active'),
mockSearchQuery: ref(''),
mockSortField: ref('role'),
mockSortDirection: ref('desc'),
mockPermissions: ref({
canViewOtherMembers: true,
canViewPendingInvites: true,
canInviteMembers: true,
canManageInvites: true,
canManageMembers: true,
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: true,
canTopUp: true
canManageMembers: true
}),
mockUiConfig: ref({
showMembersList: true,
showPendingTab: true,
showSearch: true,
showRoleColumn: true,
membersGridCols: 'grid-cols-[50%_40%_10%]',
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
headerGridCols: 'grid-cols-[50%_40%_10%]',
showEditWorkspaceMenuItem: true,
workspaceMenuAction: 'delete' as 'leave' | 'delete' | null,
workspaceMenuDisabledTooltip: null as string | null
showSearch: true
})
}
})
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({ isPaused: computed(() => false) })
}))
vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
useMembersPanel: () => ({
searchQuery: mockSearchQuery,
activeView: mockActiveView,
maxSeats: computed(() => 20),
sortField: mockSortField,
sortDirection: mockSortDirection,
maxSeats: computed(() => 50),
memberCount: computed(() => mockMembers.value.length),
isOnTeamPlan: mockIsOnTeamPlan,
hasMultipleMembers: mockHasMultipleMembers,
hasLapsedTeamPlan: computed(() => false),
showSearch: mockShowSearch,
showViewTabs: mockShowViewTabs,
showInviteButton: mockShowInviteButton,
@@ -104,7 +101,6 @@ vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
})),
filteredMembers: mockFilteredMembers,
filteredPendingInvites: mockFilteredPendingInvites,
memberMenuItems: mockMemberMenuItems,
memberMenus: computed(
() =>
new Map(
@@ -112,28 +108,21 @@ vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
)
),
isPersonalWorkspace: mockIsPersonalWorkspace,
members: mockMembers,
pendingInvites: mockPendingInvites,
permissions: mockPermissions,
uiConfig: mockUiConfig,
userPhotoUrl: ref(null),
fetchBalance: mockFetchBalance,
isCurrentUser: (m: WorkspaceMember) =>
m.email.toLowerCase() === 'owner@example.com',
isOriginalOwner: (m: WorkspaceMember) => m.id === mockOriginalOwnerId.value,
toggleSort: mockToggleSort,
showTeamPlans: mockShowTeamPlans,
handleResendInvite: mockHandleResendInvite,
handleRevokeInvite: mockHandleRevokeInvite,
handleRemoveMember: vi.fn(),
handleChangeRole: vi.fn()
handleRevokeInvite: mockHandleRevokeInvite
})
}))
vi.mock('@/components/button/MoreButton.vue', () => ({
default: (_: unknown, { slots }: { slots: Slots }) =>
h('div', slots.default?.({ close: () => {} }))
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -157,6 +146,15 @@ const SearchInputStub = {
emits: ['update:modelValue']
}
// Render the trigger slot (carries the g.moreOptions button) plus each entry as
// a flat button, so menu items are assertable without opening a real overlay.
const DropdownMenuStub = {
name: 'DropdownMenu',
props: ['entries', 'modal', 'contentClass'],
template:
'<div><slot name="button" /><button v-for="e in (entries || [])" :key="e.label" :aria-label="e.label" @click="e.command && e.command()">{{ e.label }}</button></div>'
}
function renderComponent() {
return render(MembersPanelContent, {
global: {
@@ -164,8 +162,9 @@ function renderComponent() {
stubs: {
Button: ButtonStub,
SearchInput: SearchInputStub,
DropdownMenu: DropdownMenuStub,
UserAvatar: true,
WorkspaceMenuButton: true
BillingStatusBanner: true
},
directives: { tooltip: () => {} }
}
@@ -207,7 +206,6 @@ describe('MembersPanelContent', () => {
mockFilteredPendingInvites.value = []
mockIsPersonalWorkspace.value = false
mockIsOnTeamPlan.value = true
mockHasMultipleMembers.value = true
mockShowSearch.value = true
mockShowViewTabs.value = true
mockShowInviteButton.value = true
@@ -215,27 +213,15 @@ describe('MembersPanelContent', () => {
mockActiveView.value = 'active'
mockSearchQuery.value = ''
mockPermissions.value = {
canViewOtherMembers: true,
canViewPendingInvites: true,
canInviteMembers: true,
canManageInvites: true,
canManageMembers: true,
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: true,
canTopUp: true
canManageMembers: true
}
mockUiConfig.value = {
showMembersList: true,
showPendingTab: true,
showSearch: true,
showRoleColumn: true,
membersGridCols: 'grid-cols-[50%_40%_10%]',
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
headerGridCols: 'grid-cols-[50%_40%_10%]',
showEditWorkspaceMenuItem: true,
workspaceMenuAction: 'delete',
workspaceMenuDisabledTooltip: null
showSearch: true
}
})
@@ -243,13 +229,9 @@ describe('MembersPanelContent', () => {
beforeEach(() => {
mockIsPersonalWorkspace.value = true
mockIsOnTeamPlan.value = false
mockHasMultipleMembers.value = false
mockShowSearch.value = false
mockShowViewTabs.value = false
mockIsInviteDisabled.value = true
mockUiConfig.value.showMembersList = false
mockUiConfig.value.showSearch = false
mockUiConfig.value.showPendingTab = false
})
it('shows the upsell banner below the members card', () => {
@@ -275,7 +257,7 @@ describe('MembersPanelContent', () => {
})
})
describe('team workspace - member list', () => {
describe('team workspace - member table', () => {
it('shows the Role column header and member roles', () => {
mockFilteredMembers.value = [
createMember({ role: 'owner', email: 'boss@test.com' }),
@@ -285,18 +267,47 @@ describe('MembersPanelContent', () => {
expect(
screen.getByText('workspacePanel.members.columns.role')
).toBeTruthy()
expect(screen.getByText('workspaceSwitcher.roleOwner')).toBeTruthy()
expect(screen.getByText('workspaceSwitcher.roleAdmin')).toBeTruthy()
expect(screen.getByText('workspaceSwitcher.roleMember')).toBeTruthy()
})
it('shows the Last activity and Credits columns', () => {
mockFilteredMembers.value = [createMember()]
renderComponent()
expect(
screen.getByText('workspacePanel.members.columns.lastActivity')
).toBeTruthy()
expect(
screen.getByText('workspacePanel.members.columns.creditsUsed')
).toBeTruthy()
})
it('renders the monthly credits for a member', () => {
mockFilteredMembers.value = [createMember({ creditsUsedThisMonth: 6532 })]
renderComponent()
expect(screen.getByText('6,532')).toBeTruthy()
})
it('labels the original owner as Owner and other owners as Admin', () => {
mockOriginalOwnerId.value = 'creator-1'
mockFilteredMembers.value = [
createMember({
id: 'creator-1',
email: 'creator@test.com',
role: 'owner',
isOriginalOwner: true
}),
createMember({ id: '2', email: 'admin@test.com', role: 'owner' })
]
renderComponent()
expect(screen.getByText('workspaceSwitcher.roleOwner')).toBeTruthy()
expect(screen.getByText('workspaceSwitcher.roleAdmin')).toBeTruthy()
})
it('renders filtered members', () => {
mockFilteredMembers.value = [
createMember({ name: 'Alice', email: 'alice@test.com' }),
createMember({
id: '2',
name: 'Bob',
email: 'bob@test.com'
})
createMember({ id: '2', name: 'Bob', email: 'bob@test.com' })
]
renderComponent()
expect(screen.getByText('Alice')).toBeTruthy()
@@ -388,34 +399,20 @@ describe('MembersPanelContent', () => {
describe('member role', () => {
beforeEach(() => {
mockPermissions.value = {
canViewOtherMembers: true,
canViewPendingInvites: false,
canViewPendingInvites: true,
canInviteMembers: false,
canManageInvites: false,
canManageMembers: false,
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: false,
canTopUp: false
canManageMembers: false
}
mockUiConfig.value.showPendingTab = false
mockUiConfig.value.showPendingTab = true
})
it('hides the pending tab button', () => {
it('shows the pending tab button (view-only)', () => {
mockPendingInvites.value = [createInvite()]
renderComponent()
expect(
screen.queryByText(/workspacePanel\.members\.tabs\.pendingCount/)
).toBeNull()
})
it('does not show the pending invites header', () => {
mockActiveView.value = 'pending'
mockPendingInvites.value = [createInvite()]
renderComponent()
expect(
screen.queryByText(/workspacePanel\.members\.pendingInvitesCount/)
).toBeNull()
screen.getByText(/workspacePanel\.members\.tabs\.pendingCount/)
).toBeTruthy()
})
it('shows no action menus on member rows', () => {
@@ -451,15 +448,6 @@ describe('MembersPanelContent', () => {
).toBeNull()
})
it('opens subscription dialog on upgrade click', async () => {
renderComponent()
const upgradeBtn = screen.getByRole('button', {
name: /workspacePanel\.members\.upgradeToTeam/
})
await userEvent.click(upgradeBtn)
expect(mockShowTeamPlans).toHaveBeenCalled()
})
it('hides search input', () => {
renderComponent()
expect(screen.queryByRole('textbox')).toBeNull()
@@ -472,17 +460,15 @@ describe('MembersPanelContent', () => {
})
describe('contact us footer', () => {
it('opens discord in a new tab for team workspaces on a team plan', async () => {
it('opens the team-plan request form in a new tab for team workspaces on a team plan', async () => {
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
renderComponent()
expect(
screen.getByText('workspacePanel.members.needMoreMembers')
).toBeTruthy()
expect(screen.getByText(/needMoreMembers/)).toBeTruthy()
await userEvent.click(
screen.getByText('workspacePanel.members.contactUs')
)
expect(openSpy).toHaveBeenCalledWith(
'https://www.comfy.org/discord',
'https://comfy-org.portal.usepylon.com/forms/team-plan-requests',
'_blank',
'noopener,noreferrer'
)
@@ -496,16 +482,12 @@ describe('MembersPanelContent', () => {
})
})
describe('member count display', () => {
it('shows member count header for team workspace', () => {
mockFilteredMembers.value = [
createMember({ id: '1' }),
createMember({ id: '2' })
]
mockMembers.value = mockFilteredMembers.value
describe('member count tab', () => {
it('shows the members count tab for team workspace', () => {
mockMembers.value = [createMember({ id: '1' }), createMember({ id: '2' })]
renderComponent()
expect(
screen.getByText(/workspacePanel\.members\.membersCount/)
screen.getByText(/workspacePanel\.members\.tabs\.membersCount/)
).toBeTruthy()
})
})
@@ -540,10 +522,7 @@ describe('MembersPanelContent', () => {
mockShowViewTabs.value = false
renderComponent()
expect(
screen.queryByText('workspacePanel.members.tabs.active')
).toBeNull()
expect(
screen.queryByText('workspacePanel.members.columns.role')
screen.queryByText(/workspacePanel\.members\.tabs\.pendingCount/)
).toBeNull()
})
})

View File

@@ -1,209 +1,217 @@
<template>
<div class="grow overflow-auto pt-6">
<div class="@container flex min-h-0 flex-1 flex-col gap-4 pb-6">
<!-- Header: tabs (left) + search / invite (right), above the card -->
<div
class="border-inter flex size-full flex-col gap-2 rounded-2xl border border-interface-stroke p-6"
class="flex w-full flex-col gap-3 @2xl:flex-row @2xl:items-center @2xl:gap-9"
>
<!-- Section Header -->
<div class="flex w-full items-center gap-9">
<div class="flex min-w-0 flex-1 items-baseline gap-2">
<span class="text-base font-semibold text-base-foreground">
<template v-if="activeView === 'active'">
<template v-if="isOnTeamPlan && !isPersonalWorkspace">
{{
$t('workspacePanel.members.membersCount', {
count: members.length,
maxSeats: maxSeats
})
}}
</template>
<template v-else>
{{ $t('workspacePanel.members.header') }}
</template>
</template>
<template v-else-if="permissions.canViewPendingInvites">
{{
$t(
'workspacePanel.members.pendingInvitesCount',
pendingInvites.length
)
}}
</template>
</span>
</div>
<div class="flex items-center gap-2">
<SearchInput
v-if="showSearch"
v-model="searchQuery"
:placeholder="$t('workspacePanel.members.searchPlaceholder')"
size="lg"
class="w-64"
/>
<div class="flex min-w-0 flex-1 items-center gap-2">
<template v-if="showViewTabs">
<Button
v-if="showInviteButton"
v-tooltip="
inviteTooltip
? { value: inviteTooltip, showDelay: 0 }
: { value: $t('workspacePanel.inviteMember'), showDelay: 300 }
"
variant="secondary"
:variant="activeView === 'active' ? 'secondary' : 'muted-textonly'"
size="lg"
:disabled="isInviteDisabled"
:aria-label="$t('workspacePanel.inviteMember')"
@click="handleInviteMember"
@click="activeView = 'active'"
>
{{ $t('workspacePanel.invite') }}
<i class="pi pi-plus text-sm" />
{{ $t('workspacePanel.members.tabs.membersCount', memberCount) }}
</Button>
<WorkspaceMenuButton v-if="permissions.canAccessWorkspaceMenu" />
</div>
<Button
v-if="uiConfig.showPendingTab"
:variant="activeView === 'pending' ? 'secondary' : 'muted-textonly'"
size="lg"
@click="activeView = 'pending'"
>
{{
pendingInvites.length > 0
? $t(
'workspacePanel.members.tabs.pendingCount',
pendingInvites.length
)
: $t('workspacePanel.members.tabs.pending')
}}
</Button>
</template>
<span v-else class="text-base font-normal text-base-foreground">
{{ $t('workspacePanel.members.tabs.membersCount', memberCount) }}
</span>
</div>
<!-- Members Content -->
<div class="flex min-h-0 flex-1 flex-col">
<!-- Table Header with Tab Buttons and Column Headers -->
<div
v-if="uiConfig.showMembersList && showViewTabs"
:class="
cn(
'grid w-full items-center py-2',
activeView === 'pending'
? uiConfig.pendingGridCols
: uiConfig.headerGridCols
)
<div class="flex w-full items-center gap-2 @2xl:w-auto">
<SearchInput
v-if="showSearch"
v-model="searchQuery"
:placeholder="$t('workspacePanel.members.searchPlaceholder')"
size="lg"
class="min-w-0 flex-1 @2xl:w-64 @2xl:flex-none"
/>
<Button
v-if="showInviteButton"
v-tooltip="
inviteTooltip
? { value: inviteTooltip, showDelay: 0 }
: { value: $t('workspacePanel.inviteMember'), showDelay: 300 }
"
variant="secondary"
size="lg"
class="shrink-0"
:disabled="isInviteDisabled"
:aria-label="$t('workspacePanel.inviteMember')"
@click="handleInviteMember"
>
<!-- Tab buttons in first column -->
<div class="flex items-center gap-2">
<Button
:variant="
activeView === 'active' ? 'secondary' : 'muted-textonly'
"
size="md"
@click="activeView = 'active'"
{{ $t('workspacePanel.invite') }}
<i class="icon-[lucide--plus] size-4" />
</Button>
</div>
</div>
<BillingStatusBanner />
<!-- Card: fills height, table scrolls inside -->
<div
class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-interface-stroke/60"
>
<Table v-if="activeView === 'active'" class="min-h-0 flex-1 px-4">
<TableHeader class="sticky top-0 z-10 bg-base-background">
<TableRow
class="hover:bg-transparent [&>th]:h-14 [&>th]:border-b [&>th]:border-interface-stroke/60"
>
<TableHead :aria-sort="ariaSort('email')">
<button :class="sortHeaderClass" @click="toggleSort('email')">
{{ $t('workspacePanel.members.columns.email') }}
<i :class="sortIcon('email')" />
</button>
</TableHead>
<TableHead
:class="permissions.canManageMembers ? 'w-40' : undefined"
:aria-sort="ariaSort('role')"
>
{{ $t('workspacePanel.members.tabs.active') }}
</Button>
<Button
v-if="uiConfig.showPendingTab"
:variant="
activeView === 'pending' ? 'secondary' : 'muted-textonly'
"
size="md"
@click="activeView = 'pending'"
<button :class="sortHeaderClass" @click="toggleSort('role')">
{{ $t('workspacePanel.members.columns.role') }}
<i :class="sortIcon('role')" />
</button>
</TableHead>
<TableHead
v-if="permissions.canManageMembers"
class="w-40"
:aria-sort="ariaSort('lastActivity')"
>
{{
$t(
'workspacePanel.members.tabs.pendingCount',
pendingInvites.length
)
}}
</Button>
</div>
<!-- Date column headers -->
<template v-if="activeView === 'pending'">
<Button
variant="muted-textonly"
size="sm"
class="justify-start"
@click="toggleSort('inviteDate')"
<button
:class="sortHeaderClass"
@click="toggleSort('lastActivity')"
>
{{ $t('workspacePanel.members.columns.lastActivity') }}
<i :class="sortIcon('lastActivity')" />
</button>
</TableHead>
<TableHead
v-if="permissions.canManageMembers"
class="w-64"
:aria-sort="ariaSort('credits')"
>
{{ $t('workspacePanel.members.columns.inviteDate') }}
<i class="icon-[lucide--chevrons-up-down] size-4" />
</Button>
<Button
variant="muted-textonly"
size="sm"
class="justify-start"
@click="toggleSort('expiryDate')"
>
{{ $t('workspacePanel.members.columns.expiryDate') }}
<i class="icon-[lucide--chevrons-up-down] size-4" />
</Button>
<div />
</template>
<button
:class="cn(sortHeaderClass, 'ml-auto')"
@click="toggleSort('credits')"
>
<i class="icon-[lucide--coins] size-4" />
{{ $t('workspacePanel.members.columns.creditsUsed') }}
<i :class="sortIcon('credits')" />
</button>
</TableHead>
<TableHead v-if="permissions.canManageMembers" class="w-12" />
</TableRow>
</TableHeader>
<TableBody>
<MemberTableRow
v-if="isPersonalWorkspace"
:member="personalWorkspaceMember"
:is-current-user="true"
/>
<template v-else>
<Button
variant="muted-textonly"
size="sm"
class="justify-end"
@click="toggleSort('role')"
>
{{ $t('workspacePanel.members.columns.role') }}
<i class="icon-[lucide--chevrons-up-down] size-4" />
</Button>
<!-- Empty cell for action column header (OWNER only) -->
<div v-if="permissions.canManageMembers" />
<MemberTableRow
v-for="member in filteredMembers"
:key="member.id"
:member="member"
:is-current-user="isCurrentUser(member)"
:can-manage-members="permissions.canManageMembers"
:is-original-owner="isOriginalOwner(member)"
:menu-items="memberMenus.get(member.id)"
/>
</template>
</div>
</TableBody>
</Table>
<!-- Members List -->
<div class="min-h-0 flex-1 overflow-y-auto">
<!-- Active Members -->
<template v-if="activeView === 'active'">
<!-- Personal Workspace: show only current user -->
<template v-if="isPersonalWorkspace">
<MemberListItem
:member="personalWorkspaceMember"
:is-current-user="true"
:photo-url="userPhotoUrl ?? undefined"
:grid-cols="uiConfig.membersGridCols"
/>
</template>
<!-- Team Workspace: sorted list -->
<template v-else>
<MemberListItem
v-for="(member, index) in filteredMembers"
:key="member.id"
:member="member"
:is-current-user="isCurrentUser(member)"
:photo-url="
isCurrentUser(member)
? (userPhotoUrl ?? undefined)
: undefined
"
:grid-cols="uiConfig.membersGridCols"
:show-role-column="
uiConfig.showRoleColumn && hasMultipleMembers
"
:can-manage-members="permissions.canManageMembers"
:is-single-seat-plan="!isOnTeamPlan"
:is-original-owner="isOriginalOwner(member)"
:striped="index % 2 === 1"
:menu-items="memberMenus.get(member.id)"
/>
</template>
</template>
<!-- Pending Invites -->
<PendingInvitesList
v-if="activeView === 'pending'"
:invites="filteredPendingInvites"
:grid-cols="uiConfig.pendingGridCols"
<Table v-else class="min-h-0 flex-1 px-4">
<TableHeader class="sticky top-0 z-10 bg-base-background">
<TableRow
class="hover:bg-transparent [&>th]:h-14 [&>th]:border-b [&>th]:border-interface-stroke/60"
>
<TableHead>
<span :class="sortHeaderClass">
{{ $t('workspacePanel.members.columns.email') }}
</span>
</TableHead>
<TableHead class="w-40" :aria-sort="ariaSort('inviteDate')">
<button
:class="sortHeaderClass"
@click="toggleSort('inviteDate')"
>
{{ $t('workspacePanel.members.columns.inviteDate') }}
<i :class="sortIcon('inviteDate')" />
</button>
</TableHead>
<TableHead class="w-40" :aria-sort="ariaSort('expiryDate')">
<button
:class="sortHeaderClass"
@click="toggleSort('expiryDate')"
>
{{ $t('workspacePanel.members.columns.expiryDate') }}
<i :class="sortIcon('expiryDate')" />
</button>
</TableHead>
<TableHead v-if="permissions.canManageInvites" class="w-12" />
</TableRow>
</TableHeader>
<TableBody>
<PendingInviteRow
v-for="invite in filteredPendingInvites"
:key="invite.id"
:invite="invite"
:can-manage="permissions.canManageInvites"
@resend="handleResendInvite"
@revoke="handleRevokeInvite"
/>
</div>
</div>
<TableRow
v-if="filteredPendingInvites.length === 0"
class="hover:bg-transparent"
>
<TableCell
:colspan="permissions.canManageInvites ? 4 : 3"
class="py-6 text-center text-sm text-muted-foreground"
>
{{ $t('workspacePanel.members.noInvites') }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<!-- Upsell Banner -->
<MemberUpsellBanner
v-if="!isOnTeamPlan"
:reactivate="hasLapsedTeamPlan"
@show-plans="showTeamPlans()"
/>
<!-- Need More Members Footer -->
<div
v-if="isOnTeamPlan && !isPersonalWorkspace"
class="flex items-center pt-2"
class="flex h-8 items-center"
>
<p class="text-sm text-muted-foreground">
{{ $t('workspacePanel.members.needMoreMembers') }}
{{ membersUsageLabel }}
<template v-if="permissions.canInviteMembers">
{{ $t('workspacePanel.members.needMoreMembers') }}
</template>
</p>
<Button
v-if="permissions.canInviteMembers"
variant="muted-textonly"
size="sm"
class="text-base-foreground"
size="md"
class="text-sm text-base-foreground"
@click="handleContactUs"
>
{{ $t('workspacePanel.members.contactUs') }}
@@ -215,21 +223,31 @@
<script setup lang="ts">
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Button from '@/components/ui/button/Button.vue'
import Table from '@/components/ui/table/Table.vue'
import TableBody from '@/components/ui/table/TableBody.vue'
import TableCell from '@/components/ui/table/TableCell.vue'
import TableHead from '@/components/ui/table/TableHead.vue'
import TableHeader from '@/components/ui/table/TableHeader.vue'
import TableRow from '@/components/ui/table/TableRow.vue'
import { useExternalLink } from '@/composables/useExternalLink'
import MemberListItem from '@/platform/workspace/components/dialogs/settings/MemberListItem.vue'
import BillingStatusBanner from '@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue'
import MemberTableRow from '@/platform/workspace/components/dialogs/settings/MemberTableRow.vue'
import MemberUpsellBanner from '@/platform/workspace/components/dialogs/settings/MemberUpsellBanner.vue'
import PendingInvitesList from '@/platform/workspace/components/dialogs/settings/PendingInvitesList.vue'
import WorkspaceMenuButton from '@/platform/workspace/components/dialogs/settings/WorkspaceMenuButton.vue'
import PendingInviteRow from '@/platform/workspace/components/dialogs/settings/PendingInviteRow.vue'
import { useMembersPanel } from '@/platform/workspace/composables/useMembersPanel'
import { cn } from '@comfyorg/tailwind-utils'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
const {
searchQuery,
activeView,
sortField,
sortDirection,
maxSeats,
memberCount,
isOnTeamPlan,
hasLapsedTeamPlan,
hasMultipleMembers,
showSearch,
showViewTabs,
showInviteButton,
@@ -241,11 +259,10 @@ const {
filteredPendingInvites,
memberMenus,
isPersonalWorkspace,
members,
pendingInvites,
permissions,
uiConfig,
userPhotoUrl,
fetchBalance,
isCurrentUser,
isOriginalOwner,
toggleSort,
@@ -255,8 +272,38 @@ const {
} = useMembersPanel()
const { staticUrls } = useExternalLink()
const { t } = useI18n()
// Owners get "Need more members?" after the count, where the period reads as a
// separator; members see just the count, so drop the trailing period.
const membersUsageLabel = computed(() => {
const label = t('workspacePanel.members.membersUsage', {
count: memberCount.value,
max: maxSeats.value
})
return permissions.value.canInviteMembers ? label : label.replace(/\.$/, '')
})
const sortHeaderClass =
'flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left font-[inherit] text-sm text-muted-foreground'
function sortIcon(field: string) {
if (sortField.value !== field) return 'icon-[lucide--chevrons-up-down] size-3'
return sortDirection.value === 'asc'
? 'icon-[lucide--chevron-up] size-3'
: 'icon-[lucide--chevron-down] size-3'
}
function ariaSort(field: string): 'ascending' | 'descending' | 'none' {
if (sortField.value !== field) return 'none'
return sortDirection.value === 'asc' ? 'ascending' : 'descending'
}
function handleContactUs() {
window.open(staticUrls.discord, '_blank', 'noopener,noreferrer')
window.open(staticUrls.teamPlanRequests, '_blank', 'noopener,noreferrer')
}
onMounted(() => {
void fetchBalance()
})
</script>

View File

@@ -0,0 +1,25 @@
<template>
<span
class="flex size-5 shrink-0 items-center justify-center rounded-full bg-secondary-background-hover"
>
<i :class="cn(getProviderIcon(partner), 'size-3')" :style="iconStyle" />
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { getProviderBorderStyle, getProviderIcon } from '@/utils/categoryUtil'
import { cn } from '@comfyorg/tailwind-utils'
const { partner } = defineProps<{ partner: string }>()
// Monotone provider glyphs (Anthropic, BFL, …) render in currentColor, so tint
// them with the brand color. Multi-color brands (ByteDance, Kling, Gemini, …)
// ship full-color icons — identified by a gradient brand color — and must be
// left untouched or the tint replaces their artwork.
const iconStyle = computed(() => {
const style = getProviderBorderStyle(partner)
return style.includes('gradient') ? undefined : { color: style }
})
</script>

View File

@@ -0,0 +1,250 @@
<template>
<div class="@container relative flex min-h-0 flex-1 flex-col gap-4 pb-6">
<div
class="flex w-full flex-col gap-3 @2xl:flex-row @2xl:items-center @2xl:gap-9"
>
<span class="min-w-0 flex-1 text-sm text-muted-foreground">
{{ $t('workspacePanel.partnerNodes.description') }}
</span>
<SearchInput
v-model="searchQuery"
:placeholder="$t('workspacePanel.partnerNodes.searchPlaceholder')"
size="lg"
class="w-full @2xl:w-64"
/>
</div>
<BillingStatusBanner />
<div
class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-interface-stroke/60"
>
<Table class="min-h-0 flex-1 scrollbar-gutter-stable px-4">
<TableHeader class="sticky top-0 z-10 bg-base-background">
<TableRow
class="hover:bg-transparent [&>th]:h-14 [&>th]:border-b [&>th]:border-interface-stroke/60"
>
<TableHead class="w-6">
<Checkbox
:model-value="allFilteredSelected"
:aria-label="$t('workspacePanel.partnerNodes.selectAll')"
@update:model-value="toggleSelectAll"
/>
</TableHead>
<TableHead :aria-sort="ariaSort('name')">
<button :class="sortHeaderClass" @click="toggleSort('name')">
{{ $t('workspacePanel.partnerNodes.columns.name') }}
<i :class="sortIcon('name')" />
</button>
</TableHead>
<TableHead class="w-40" :aria-sort="ariaSort('partner')">
<button :class="sortHeaderClass" @click="toggleSort('partner')">
{{ $t('workspacePanel.partnerNodes.columns.partner') }}
<i :class="sortIcon('partner')" />
</button>
</TableHead>
<TableHead class="w-40" :aria-sort="ariaSort('lastModified')">
<button
:class="sortHeaderClass"
@click="toggleSort('lastModified')"
>
{{ $t('workspacePanel.partnerNodes.columns.lastModified') }}
<i :class="sortIcon('lastModified')" />
</button>
</TableHead>
<TableHead class="w-14" />
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="node in filteredNodes"
:key="node.id"
:data-state="selectedIds.has(node.id) ? 'selected' : undefined"
class="group cursor-pointer hover:bg-transparent data-[state=selected]:bg-transparent [&:hover>td]:bg-secondary-background/50 [&:last-child>td]:border-b-0 [&>td]:border-b [&>td]:border-interface-stroke/20 [&>td]:transition-colors [&[data-state=selected]>td]:bg-secondary-background/50"
@click="toggleSelection(node.id)"
>
<TableCell>
<Checkbox
:model-value="selectedIds.has(node.id)"
:aria-label="node.name"
:class="
cn(
'pointer-events-none',
!hasSelection &&
'opacity-0 transition-opacity group-hover:opacity-100'
)
"
/>
</TableCell>
<TableCell class="text-muted-foreground">
<span :class="cn(!node.enabled && 'opacity-30')">
{{ node.name }}
</span>
</TableCell>
<TableCell class="text-muted-foreground">
<div
:class="
cn('flex items-center gap-2', !node.enabled && 'opacity-30')
"
>
<PartnerBadge :partner="node.partner" />
<span>{{ node.partner }}</span>
</div>
</TableCell>
<TableCell class="text-muted-foreground">
{{ formatLastModified(node.last_modified) }}
</TableCell>
<TableCell class="text-right" @click.stop>
<Switch
:model-value="node.enabled"
@update:model-value="(v: boolean) => setEnabled(node, v)"
/>
</TableCell>
</TableRow>
<TableRow
v-if="filteredNodes.length === 0"
class="hover:bg-transparent"
>
<TableCell
:colspan="5"
class="py-6 text-center text-sm text-muted-foreground"
>
{{ $t('workspacePanel.partnerNodes.empty') }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<!-- Auto-enable default: outside the card, pinned to the panel bottom. pr-6
lines the toggle up with the in-table row toggles (table px-4 + cell px-2);
the reserved scrollbar gutter mirrors the table's so the two stay aligned
whether or not the list is scrolling. -->
<div
class="flex h-8 scrollbar-gutter-stable items-center justify-end gap-2 overflow-y-auto pr-6 text-sm text-muted-foreground"
>
<span>{{ $t('workspacePanel.partnerNodes.autoEnableLabel') }}</span>
<!-- Both strings occupy the same grid cell so its width is fixed to the
longer one; only the active label is visible, so the row never reflows. -->
<span class="grid justify-items-end text-base-foreground">
<span
:class="cn('col-start-1 row-start-1', !autoEnableNew && 'invisible')"
>
{{ $t('workspacePanel.partnerNodes.autoEnabled') }}
</span>
<span
:class="cn('col-start-1 row-start-1', autoEnableNew && 'invisible')"
>
{{ $t('workspacePanel.partnerNodes.autoDisabled') }}
</span>
</span>
<Switch
:model-value="autoEnableNew"
@update:model-value="setAutoEnableNew"
/>
</div>
<!-- Bulk selection toolbar: overlaid so toggling it doesn't reflow the panel -->
<div class="absolute inset-x-0 bottom-0">
<Transition
enter-active-class="transition-opacity duration-150"
leave-active-class="transition-opacity duration-150"
enter-from-class="opacity-0"
leave-to-class="opacity-0"
>
<SelectionBar
v-if="selectedCount > 0"
:label="
$t('workspacePanel.partnerNodes.selectedCount', selectedCount)
"
:deselect-label="$t('workspacePanel.partnerNodes.clearSelection')"
@deselect="clearSelection"
>
<Switch :model-value="bulkEnabled" @update:model-value="applyBulk" />
</SelectionBar>
</Transition>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import SelectionBar from '@/components/common/SelectionBar.vue'
import Checkbox from '@/components/ui/checkbox/Checkbox.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Switch from '@/components/ui/switch/Switch.vue'
import Table from '@/components/ui/table/Table.vue'
import TableBody from '@/components/ui/table/TableBody.vue'
import TableCell from '@/components/ui/table/TableCell.vue'
import TableHead from '@/components/ui/table/TableHead.vue'
import TableHeader from '@/components/ui/table/TableHeader.vue'
import TableRow from '@/components/ui/table/TableRow.vue'
import BillingStatusBanner from '@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue'
import PartnerBadge from '@/platform/workspace/components/dialogs/settings/PartnerBadge.vue'
import { usePartnerNodes } from '@/platform/workspace/composables/usePartnerNodes'
import { cn } from '@comfyorg/tailwind-utils'
const { t } = useI18n()
const {
autoEnableNew,
searchQuery,
sortField,
sortDirection,
selectedIds,
selectedCount,
allFilteredSelected,
filteredNodes,
fetch,
toggleSort,
setEnabled,
setSelectedEnabled,
setAutoEnableNew,
toggleSelection,
toggleSelectAll,
clearSelection
} = usePartnerNodes()
const hasSelection = computed(() => selectedCount.value > 0)
const sortHeaderClass =
'flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left font-[inherit] text-sm text-muted-foreground'
function sortIcon(field: 'name' | 'partner' | 'lastModified') {
if (sortField.value !== field) return 'icon-[lucide--chevrons-up-down] size-3'
return sortDirection.value === 'asc'
? 'icon-[lucide--chevron-up] size-3'
: 'icon-[lucide--chevron-down] size-3'
}
function ariaSort(
field: 'name' | 'partner' | 'lastModified'
): 'ascending' | 'descending' | 'none' {
if (sortField.value !== field) return 'none'
return sortDirection.value === 'asc' ? 'ascending' : 'descending'
}
// When every selected node is enabled the bulk switch reads "on", so a toggle
// disables the whole selection; otherwise it enables them.
const bulkEnabled = computed(() =>
filteredNodes.value
.filter((n) => selectedIds.value.has(n.id))
.every((n) => n.enabled)
)
function applyBulk(value: boolean) {
void setSelectedEnabled(value)
}
function formatLastModified(iso: string | null): string {
if (!iso) return t('workspacePanel.partnerNodes.neverModified')
return new Date(iso).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
})
}
onMounted(fetch)
</script>

View File

@@ -0,0 +1,88 @@
<template>
<TableRow
:data-testid="`invite-row-${invite.id}`"
class="group hover:bg-transparent"
>
<TableCell>
<div class="flex items-center gap-3">
<span
class="flex size-8 shrink-0 items-center justify-center rounded-full"
:style="{ backgroundColor: userBadgeColor(invite.email) }"
>
<span class="text-sm font-bold text-base-foreground">
{{ inviteInitial }}
</span>
</span>
<span class="min-w-0 flex-1 truncate text-sm text-base-foreground">
{{ invite.email }}
</span>
</div>
</TableCell>
<TableCell class="text-sm text-muted-foreground">
{{ formatDate(invite.inviteDate) }}
</TableCell>
<TableCell class="text-sm text-muted-foreground">
{{ formatDate(invite.expiryDate) }}
</TableCell>
<TableCell v-if="canManage" class="text-right" @click.stop>
<DropdownMenu
:entries="menuItems"
:modal="false"
content-class="min-w-44"
>
<template #button>
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="muted-textonly"
size="icon"
:aria-label="$t('g.moreOptions')"
>
<i class="pi pi-ellipsis-h" />
</Button>
</template>
</DropdownMenu>
</TableCell>
</TableRow>
</template>
<script setup lang="ts">
import type { MenuItem } from 'primevue/menuitem'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import DropdownMenu from '@/components/common/DropdownMenu.vue'
import Button from '@/components/ui/button/Button.vue'
import TableCell from '@/components/ui/table/TableCell.vue'
import TableRow from '@/components/ui/table/TableRow.vue'
import type { PendingInvite } from '@/platform/workspace/stores/teamWorkspaceStore'
import { userBadgeColor } from '@/platform/workspace/utils/badgeColor'
const { invite, canManage } = defineProps<{
invite: PendingInvite
canManage: boolean
}>()
const emit = defineEmits<{
resend: [invite: PendingInvite]
revoke: [invite: PendingInvite]
}>()
const { t, d } = useI18n()
const inviteInitial = computed(() => invite.email.charAt(0).toUpperCase())
const menuItems = computed<MenuItem[]>(() => [
{
label: t('workspacePanel.members.actions.resendInvite'),
command: () => emit('resend', invite)
},
{
label: t('workspacePanel.members.actions.cancelInvite'),
command: () => emit('revoke', invite)
}
])
function formatDate(date: Date): string {
return d(date, { dateStyle: 'medium' })
}
</script>

View File

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

View File

@@ -1,112 +0,0 @@
<template>
<div>
<div
v-for="(invite, index) in invites"
:key="invite.id"
:class="
cn(
'grid w-full items-center rounded-lg p-2',
gridCols,
index % 2 === 1 && 'bg-secondary-background/50'
)
"
>
<div class="flex items-center gap-3">
<div
class="flex size-8 shrink-0 items-center justify-center rounded-full bg-secondary-background"
>
<span class="text-sm font-bold text-base-foreground">
{{ getInviteInitial(invite.email) }}
</span>
</div>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<span class="text-sm text-base-foreground">
{{ getInviteDisplayName(invite.email) }}
</span>
<span class="text-sm text-muted-foreground">
{{ invite.email }}
</span>
</div>
</div>
<span class="text-sm text-muted-foreground">
{{ formatDate(invite.inviteDate) }}
</span>
<span class="text-sm text-muted-foreground">
{{ formatDate(invite.expiryDate) }}
</span>
<div class="flex items-center justify-end">
<MoreButton v-slot="{ close }" :aria-label="$t('g.moreOptions')">
<Button
variant="textonly"
size="unset"
:class="menuItemClass"
@click="
() => {
close()
$emit('resend', invite)
}
"
>
<i class="icon-[lucide--mail-plus] size-4" />
<span>{{ $t('workspacePanel.members.actions.resendInvite') }}</span>
</Button>
<Button
variant="textonly"
size="unset"
:class="menuItemClass"
@click="
() => {
close()
$emit('revoke', invite)
}
"
>
<i class="icon-[lucide--mail-x] size-4" />
<span>{{ $t('workspacePanel.members.actions.cancelInvite') }}</span>
</Button>
</MoreButton>
</div>
</div>
<div
v-if="invites.length === 0"
class="flex w-full items-center justify-center py-8 text-sm text-muted-foreground"
>
{{ $t('workspacePanel.members.noInvites') }}
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import MoreButton from '@/components/button/MoreButton.vue'
import Button from '@/components/ui/button/Button.vue'
import type { PendingInvite } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@comfyorg/tailwind-utils'
const menuItemClass = 'w-full justify-start rounded-sm px-3 py-2'
defineProps<{
invites: PendingInvite[]
gridCols: string
}>()
defineEmits<{
resend: [invite: PendingInvite]
revoke: [invite: PendingInvite]
}>()
const { d } = useI18n()
function getInviteDisplayName(email: string): string {
return email.split('@')[0]
}
function getInviteInitial(email: string): string {
return email.charAt(0).toUpperCase()
}
function formatDate(date: Date): string {
return d(date, { dateStyle: 'medium' })
}
</script>

View File

@@ -0,0 +1,107 @@
<template>
<div
:class="
cn(
'@container flex min-h-0 flex-1 flex-col gap-4',
// The panel runs flush to the bottom edge (BaseModalLayout 'flush'); the
// Activity/Invoices tables keep their prior bottom gap, Overview doesn't.
activeView !== 'overview' && 'pb-6'
)
"
>
<div
class="flex w-full flex-col gap-3 @2xl:flex-row @2xl:items-center @2xl:gap-9"
>
<div class="flex min-w-0 flex-1 items-center gap-2">
<Button
v-for="tab in tabs"
:key="tab.key"
:variant="activeView === tab.key ? 'secondary' : 'muted-textonly'"
size="lg"
@click="setView(tab.key)"
>
{{ tab.label }}
</Button>
</div>
<SearchInput
v-if="activeView === 'activity'"
v-model="searchQuery"
:placeholder="$t('g.search')"
size="lg"
class="w-full @2xl:w-64"
/>
</div>
<BillingStatusBanner>
<template #actions>
<Button
v-if="activeView === 'invoices' && isPaused"
variant="textonly"
size="lg"
@click="openInvoiceHistory"
>
{{ $t('workspacePanel.invoices.fullHistory') }}
<i class="icon-[lucide--external-link] size-4" />
</Button>
</template>
</BillingStatusBanner>
<WorkspaceOverviewContent
v-if="activeView === 'overview'"
@navigate="setView"
/>
<WorkspaceActivityContent
v-else-if="activeView === 'activity'"
:search="searchQuery"
/>
<WorkspaceInvoicesContent v-else />
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Button from '@/components/ui/button/Button.vue'
import BillingStatusBanner from '@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue'
import WorkspaceActivityContent from '@/platform/workspace/components/dialogs/settings/WorkspaceActivityContent.vue'
import WorkspaceOverviewContent from '@/platform/workspace/components/dialogs/settings/WorkspaceOverviewContent.vue'
import WorkspaceInvoicesContent from '@/platform/workspace/components/dialogs/settings/WorkspaceInvoicesContent.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { cn } from '@comfyorg/tailwind-utils'
type View = 'overview' | 'activity' | 'invoices'
const { t } = useI18n()
const { permissions } = useWorkspaceUI()
const { isPaused, manageSubscription } = useBillingContext()
function openInvoiceHistory() {
void manageSubscription()
}
const tabs = computed(() => {
const base: { key: View; label: string }[] = [
{ key: 'overview', label: t('workspacePanel.planCredits.tabs.overview') },
{ key: 'activity', label: t('workspacePanel.planCredits.tabs.activity') }
]
// Invoices are billing details — owners/admins only.
if (permissions.value.canManageSubscription) {
base.push({
key: 'invoices',
label: t('workspacePanel.planCredits.tabs.invoices')
})
}
return base
})
const activeView = ref<View>('overview')
const searchQuery = ref('')
function setView(view: View) {
activeView.value = view
searchQuery.value = ''
}
</script>

View File

@@ -0,0 +1,18 @@
<template>
<div
class="h-2 w-full overflow-hidden rounded-full bg-secondary-background-hover"
>
<div
class="h-full rounded-full bg-credit transition-[width]"
:style="{ width: `${clamped * 100}%` }"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const { value } = defineProps<{ value: number }>()
const clamped = computed(() => Math.min(1, Math.max(0, value)))
</script>

View File

@@ -0,0 +1,307 @@
<template>
<div class="flex min-h-0 flex-1 flex-col gap-4">
<div
ref="tableContainer"
class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-interface-stroke/60"
>
<Table class="min-h-0 flex-1 px-4">
<TableHeader class="sticky top-0 z-10 bg-base-background">
<TableRow
class="hover:bg-transparent [&>th]:h-14 [&>th]:border-b [&>th]:border-interface-stroke/60"
>
<TableHead class="w-40" :aria-sort="ariaSort('date')">
<button :class="sortHeaderClass" @click="toggleSort('date')">
{{ $t('workspacePanel.activity.columns.date') }}
<i :class="sortIcon('date')" />
</button>
</TableHead>
<TableHead class="w-56" :aria-sort="ariaSort('user')">
<button :class="sortHeaderClass" @click="toggleSort('user')">
{{ $t('workspacePanel.activity.columns.user') }}
<i :class="sortIcon('user')" />
</button>
</TableHead>
<TableHead :aria-sort="ariaSort('eventType')">
<button :class="sortHeaderClass" @click="toggleSort('eventType')">
{{ $t('workspacePanel.activity.columns.eventType') }}
<i :class="sortIcon('eventType')" />
</button>
</TableHead>
<TableHead class="w-32" :aria-sort="ariaSort('detail')">
<button :class="sortHeaderClass" @click="toggleSort('detail')">
{{ $t('workspacePanel.activity.columns.eventDetails') }}
<i :class="sortIcon('detail')" />
</button>
</TableHead>
<TableHead class="w-40" :aria-sort="ariaSort('credits')">
<button
:class="cn(sortHeaderClass, 'ml-auto')"
@click="toggleSort('credits')"
>
<i class="icon-[lucide--coins] size-4" />
{{ $t('workspacePanel.activity.columns.creditsUsed') }}
<i :class="sortIcon('credits')" />
</button>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="event in pagedItems"
:key="event.id"
class="hover:bg-transparent [&:last-child>td]:border-b-0 [&>td]:border-b [&>td]:border-interface-stroke/20"
>
<TableCell class="text-sm text-muted-foreground tabular-nums">
{{ formatDate(event.date) }}
</TableCell>
<TableCell>
<HoverCard
v-if="event.userName"
:open-delay="150"
:close-delay="0"
>
<HoverCardTrigger
as="div"
class="flex w-fit cursor-default items-center gap-3"
>
<span
class="flex size-5 shrink-0 items-center justify-center rounded-full"
:style="{ backgroundColor: userBadgeColor(event.userName) }"
>
<span class="text-2xs font-bold text-base-foreground">
{{ event.userName.charAt(0).toUpperCase() }}
</span>
</span>
<span class="truncate text-sm text-base-foreground">
{{ event.userName }}
</span>
</HoverCardTrigger>
<HoverCardContent class="w-64" align="start">
<div class="flex w-full flex-col gap-2">
<div class="flex h-5 items-center justify-between">
<span class="text-sm text-muted-foreground">
{{
$t(
'workspacePanel.activity.hoverCard.totalCreditsUsed'
)
}}
</span>
<span class="flex items-center gap-1">
<i
class="icon-[lucide--coins] size-4 text-muted-foreground"
/>
<span class="text-sm text-base-foreground tabular-nums">
{{
summaryFor(
event.userName
).totalCredits.toLocaleString()
}}
</span>
</span>
</div>
<div class="flex h-5 items-center justify-between">
<span class="text-sm text-muted-foreground">
{{
$t('workspacePanel.activity.hoverCard.lastActivity')
}}
</span>
<span class="text-sm text-base-foreground">
{{ lastActivityLabel(event.userName) }}
</span>
</div>
</div>
</HoverCardContent>
</HoverCard>
<span v-else class="text-sm text-muted-foreground"></span>
</TableCell>
<TableCell class="text-sm text-muted-foreground">
<HoverCard
v-if="event.partnerNode"
:open-delay="150"
:close-delay="0"
>
<HoverCardTrigger as="span" class="cursor-default">
{{ event.eventType }}
</HoverCardTrigger>
<HoverCardContent class="w-72" align="start">
<div class="flex h-5 items-center justify-between gap-4">
<span
class="text-sm whitespace-nowrap text-muted-foreground"
>
{{
$t('workspacePanel.activity.hoverCard.partnerNodeUsed')
}}
</span>
<span class="truncate text-sm text-base-foreground">
{{ event.partnerNode }}
</span>
</div>
</HoverCardContent>
</HoverCard>
<template v-else>{{ event.eventType }}</template>
</TableCell>
<TableCell class="text-sm text-muted-foreground tabular-nums">
{{ event.detail || '—' }}
</TableCell>
<TableCell
:class="
cn(
'text-right text-sm tabular-nums',
event.credited ? 'text-credit' : 'text-muted-foreground'
)
"
>
{{ event.credited ? '+' : ''
}}{{ event.credits.toLocaleString() }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<div class="flex flex-col gap-3 @2xl:h-8 @2xl:flex-row @2xl:items-center">
<div
v-if="canViewTeamUsage"
class="flex flex-wrap items-center gap-x-6 gap-y-2"
>
<a
:href="fullActivityUrl"
target="_blank"
rel="noopener noreferrer"
class="flex cursor-pointer items-center gap-1 text-sm text-muted-foreground no-underline transition-colors hover:text-base-foreground"
>
<i class="icon-[lucide--external-link] size-4" />
{{ $t('workspacePanel.activity.fullActivity') }}
</a>
<div class="flex items-center gap-3">
<p class="text-sm text-muted-foreground">
{{ $t('workspacePanel.activity.perUserHint') }}
</p>
<button
class="flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 font-[inherit] text-sm text-base-foreground transition-colors hover:text-muted-foreground"
@click="goToMembers"
>
{{ $t('workspacePanel.activity.seeMembers') }}
<i class="icon-[lucide--arrow-right] size-4" />
</button>
</div>
</div>
<Pagination
v-model:page="page"
:total="total"
:items-per-page="itemsPerPage"
class="@2xl:ml-auto"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import HoverCard from '@/components/ui/hover-card/HoverCard.vue'
import HoverCardContent from '@/components/ui/hover-card/HoverCardContent.vue'
import HoverCardTrigger from '@/components/ui/hover-card/HoverCardTrigger.vue'
import Pagination from '@/components/ui/pagination/Pagination.vue'
import Table from '@/components/ui/table/Table.vue'
import TableBody from '@/components/ui/table/TableBody.vue'
import TableCell from '@/components/ui/table/TableCell.vue'
import TableHead from '@/components/ui/table/TableHead.vue'
import TableHeader from '@/components/ui/table/TableHeader.vue'
import TableRow from '@/components/ui/table/TableRow.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import { useSettingsNavigation } from '@/platform/settings/composables/useSettingsNavigation'
import { useAutoPageSize } from '@/platform/workspace/composables/useAutoPageSize'
import { requestMembersSort } from '@/platform/workspace/composables/useMembersPanel'
import { useWorkspaceActivity } from '@/platform/workspace/composables/useWorkspaceActivity'
import type { ActivitySortField } from '@/platform/workspace/composables/useWorkspaceActivity'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { userBadgeColor } from '@/platform/workspace/utils/badgeColor'
import { formatRelativeTime } from '@/platform/workspace/utils/relativeTime'
import { cn } from '@comfyorg/tailwind-utils'
const { search } = defineProps<{ search: string }>()
const { t, d } = useI18n()
const tableContainer = ref<HTMLElement | null>(null)
const { pageSize } = useAutoPageSize(tableContainer, 1)
// Owners/admins see team-wide activity + the per-user footer; members see only
// their own usage, scoped to their name.
const { permissions } = useWorkspaceUI()
const { userDisplayName, userEmail } = useCurrentUser()
const canViewTeamUsage = computed(() => permissions.value.canManageSubscription)
const selfName = computed(() =>
canViewTeamUsage.value
? null
: userDisplayName.value || userEmail.value || t('g.you')
)
const fullActivityUrl = `${getComfyPlatformBaseUrl()}/profile/usage`
const { navigateToPanel } = useSettingsNavigation()
function goToMembers() {
requestMembersSort('credits')
navigateToPanel('workspace-members')
}
const {
page,
total,
itemsPerPage,
pagedItems,
sortField,
sortDirection,
toggleSort,
userSummaries
} = useWorkspaceActivity(() => search, pageSize, selfName)
function summaryFor(userName: string) {
return (
userSummaries.value.get(userName) ?? {
totalCredits: 0,
lastActivity: new Date()
}
)
}
function lastActivityLabel(userName: string): string {
return formatRelativeTime(summaryFor(userName).lastActivity, new Date(), {
justNow: t('workspacePanel.members.activity.justNow'),
minutesAgo: (n) => t('workspacePanel.members.activity.minutesAgo', { n }),
hoursAgo: (n) => t('workspacePanel.members.activity.hoursAgo', { n }),
daysAgo: (n) => t('workspacePanel.members.activity.daysAgo', n)
})
}
const sortHeaderClass =
'flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left font-[inherit] text-sm text-muted-foreground'
function sortIcon(field: ActivitySortField) {
if (sortField.value !== field) return 'icon-[lucide--chevrons-up-down] size-3'
return sortDirection.value === 'asc'
? 'icon-[lucide--chevron-up] size-3'
: 'icon-[lucide--chevron-down] size-3'
}
function ariaSort(
field: ActivitySortField
): 'ascending' | 'descending' | 'none' {
if (sortField.value !== field) return 'none'
return sortDirection.value === 'asc' ? 'ascending' : 'descending'
}
function formatDate(date: Date): string {
return d(date, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true
})
}
</script>

View File

@@ -0,0 +1,58 @@
<template>
<div class="flex min-h-0 flex-1 flex-col gap-4">
<!-- Invoice history and downloads live in Stripe, so this tab just surfaces
the upcoming charge and links out; the local history table is hidden
for now. When paused there's no upcoming invoice — the parent's
"Subscription paused" banner covers that state and hosts the history
link, so we drop this banner to avoid stacking two. -->
<div
v-if="!isPaused"
class="flex flex-col gap-4 rounded-2xl border border-interface-stroke/60 p-4 @2xl:flex-row @2xl:items-center @2xl:justify-between"
>
<div class="flex flex-col gap-2">
<span class="text-sm text-muted-foreground">
{{ $t('workspacePanel.overview.nextInvoice') }}
</span>
<p class="m-0 text-2xl font-semibold text-base-foreground">
{{ formatWholeUsd(nextInvoiceCents) }}
<span class="text-base font-normal text-base-foreground">
{{ $t('workspacePanel.overview.usd') }}
</span>
</p>
</div>
<Button variant="secondary" size="lg" @click="openHistory">
{{ $t('workspacePanel.invoices.fullHistory') }}
<i class="icon-[lucide--external-link] size-4" />
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useWorkspaceInvoices } from '@/platform/workspace/composables/useWorkspaceInvoices'
const { n } = useI18n()
const { isPaused, manageSubscription } = useBillingContext()
const { nextInvoiceCents } = useWorkspaceInvoices(
() => '',
() => 1
)
function formatWholeUsd(cents: number): string {
return n(cents / 100, {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0
})
}
function openHistory() {
void manageSubscription()
}
</script>

View File

@@ -0,0 +1,21 @@
<template>
<div class="flex size-full flex-col">
<MembersPanelContent :key="workspaceRole" />
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import MembersPanelContent from '@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
const { workspaceRole } = useWorkspaceUI()
const { fetchMembers, fetchPendingInvites } = useTeamWorkspaceStore()
onMounted(() => {
void fetchMembers()
void fetchPendingInvites()
})
</script>

View File

@@ -5,6 +5,7 @@ import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import { useWorkspaceRename } from '@/platform/workspace/composables/useWorkspaceRename'
import WorkspaceMenuButton from './WorkspaceMenuButton.vue'
@@ -75,6 +76,25 @@ describe('WorkspaceMenuButton', () => {
mockUiConfig.value = ownerConfig
mockIsCurrentUserOriginalOwner.value = false
mockIsWorkspaceSubscribed.value = false
useWorkspaceRename().stopRenaming()
})
it('offers Rename to an editor and starts inline renaming', async () => {
const user = userEvent.setup()
const { isRenaming } = useWorkspaceRename()
renderComponent()
await user.click(screen.getByRole('button', { name: 'Rename Workspace' }))
expect(isRenaming.value).toBe(true)
})
it('hides Rename from a member', () => {
mockUiConfig.value = memberConfig
renderComponent()
expect(
screen.queryByRole('button', { name: 'Rename Workspace' })
).not.toBeInTheDocument()
})
it('lets a member leave and offers no destructive workspace actions', () => {

View File

@@ -1,10 +1,16 @@
<template>
<DropdownMenu :entries="menuItems">
<DropdownMenu
v-if="menuItems.length > 0"
:entries="menuItems"
:modal="false"
@close-auto-focus="onMenuCloseAutoFocus"
>
<template #button>
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="muted-textonly"
variant="secondary"
size="icon-lg"
class="rounded-lg"
:aria-label="$t('g.moreOptions')"
>
<i class="pi pi-ellipsis-h" />
@@ -21,20 +27,35 @@ import { useI18n } from 'vue-i18n'
import DropdownMenu from '@/components/common/DropdownMenu.vue'
import Button from '@/components/ui/button/Button.vue'
import { useWorkspaceRename } from '@/platform/workspace/composables/useWorkspaceRename'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogService } from '@/services/dialogService'
const { t } = useI18n()
const {
showLeaveWorkspaceDialog,
showDeleteWorkspaceDialog,
showEditWorkspaceDialog
} = useDialogService()
const { showLeaveWorkspaceDialog, showDeleteWorkspaceDialog } =
useDialogService()
const { isWorkspaceSubscribed, isCurrentUserOriginalOwner } = storeToRefs(
useTeamWorkspaceStore()
)
const { uiConfig } = useWorkspaceUI()
const { startRenaming } = useWorkspaceRename()
// Reka returns focus to the trigger when the menu closes, which would blur (and
// so tear down) the rename input we're about to focus. Suppress that focus
// restoration for the one close that kicks off a rename.
let renameStarting = false
function beginRename() {
renameStarting = true
startRenaming()
}
function onMenuCloseAutoFocus(event: Event) {
if (!renameStarting) return
renameStarting = false
event.preventDefault()
}
// Disable delete when the workspace has an active subscription (prevents
// accidental deletion); uses the workspace's own status, not the global one.
@@ -51,22 +72,21 @@ const deleteTooltip = computed(() => {
})
const menuItems = computed<MenuItem[]>(() => {
const items: MenuItem[] = []
if (uiConfig.value.showEditWorkspaceMenuItem) {
items.push({
label: t('workspacePanel.menu.editWorkspace'),
icon: 'pi pi-pencil',
command: () => showEditWorkspaceDialog()
})
}
const renameItems: MenuItem[] = uiConfig.value.showEditWorkspaceMenuItem
? [
{
label: t('workspacePanel.menu.renameWorkspace'),
command: beginRename
}
]
: []
const destructiveItems: MenuItem[] = []
const action = uiConfig.value.workspaceMenuAction
if (action === 'delete') {
items.push({
destructiveItems.push({
label: t('workspacePanel.menu.deleteWorkspace'),
icon: 'pi pi-trash',
class: isDeleteDisabled.value ? 'text-danger/50' : 'text-danger',
class: isDeleteDisabled.value ? undefined : 'text-danger',
disabled: isDeleteDisabled.value,
tooltip: deleteTooltip.value,
command: isDeleteDisabled.value
@@ -77,23 +97,23 @@ const menuItems = computed<MenuItem[]>(() => {
// Members and non-creator owners can leave; the creator sees it disabled.
if (action === 'leave' || action === 'delete') {
items.push(
destructiveItems.push(
isCurrentUserOriginalOwner.value
? {
label: t('workspacePanel.menu.leaveWorkspace'),
icon: 'pi pi-sign-out',
class: 'opacity-50',
disabled: true,
tooltip: t('workspacePanel.menu.creatorCannotLeave')
}
: {
label: t('workspacePanel.menu.leaveWorkspace'),
icon: 'pi pi-sign-out',
command: () => showLeaveWorkspaceDialog()
}
)
}
return items
const divider: MenuItem[] =
renameItems.length && destructiveItems.length ? [{ separator: true }] : []
return [...renameItems, ...divider, ...destructiveItems]
})
</script>

View File

@@ -0,0 +1,366 @@
<template>
<div
class="flex min-h-0 scrollbar-hide flex-1 flex-col gap-4 overflow-y-auto pb-6"
>
<!-- Plan + credits + member snapshot -->
<div
class="flex flex-col gap-6 rounded-2xl border border-interface-stroke/60 p-6"
>
<!-- Lapsed team/enterprise plan: reactivation header replaces the live one -->
<div
v-if="isInactive"
class="flex flex-col gap-4 @4xl:flex-row @4xl:items-start @4xl:justify-between"
>
<div class="flex flex-col gap-1">
<span class="text-sm text-base-foreground">
{{ $t('workspacePanel.overview.inactive.title') }}
</span>
<span class="text-sm text-muted-foreground">
{{ $t('workspacePanel.overview.inactive.subtitle') }}
</span>
</div>
<div v-if="canManageBilling" class="flex shrink-0 items-center gap-2">
<Button variant="secondary" size="lg">
{{ $t('workspacePanel.overview.managePayment') }}
</Button>
<Button
variant="inverted"
size="lg"
:loading="isResubscribing"
@click="handleResubscribe"
>
{{ $t('workspacePanel.overview.inactive.reactivate') }}
</Button>
</div>
</div>
<div
v-else
class="flex flex-col gap-4 @4xl:flex-row @4xl:items-start @4xl:justify-between"
>
<div class="flex flex-col gap-1">
<span class="text-sm text-base-foreground">{{ plan.name }}</span>
<p
v-if="canManageBilling"
class="m-0 flex items-center gap-1.5 text-base font-semibold whitespace-nowrap text-base-foreground"
>
<i class="icon-[lucide--coins] size-4 text-credit" />
{{ plan.monthlyCredits.toLocaleString() }}
<span class="text-base font-normal text-muted-foreground">
/ {{ $t('workspacePanel.overview.perMonth') }}
</span>
</p>
<span class="text-sm text-muted-foreground">
{{
isPaused
? $t('workspacePanel.overview.paused')
: $t('workspacePanel.overview.renewsOn', {
date: plan.renewalLabel
})
}}
</span>
</div>
<div v-if="canManageBilling" class="flex shrink-0 items-center gap-2">
<Button variant="secondary" size="lg">
{{ $t('workspacePanel.overview.managePayment') }}
</Button>
<Button v-if="isOriginalOwner" variant="secondary" size="lg">
{{ $t('workspacePanel.overview.changePlan') }}
</Button>
<DropdownMenu
v-if="planMenuEntries.length > 0"
:entries="planMenuEntries"
:modal="false"
>
<template #button>
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="secondary"
size="icon-lg"
class="rounded-lg"
:aria-label="$t('g.moreOptions')"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</template>
</DropdownMenu>
</div>
</div>
<div class="grid grid-cols-1 gap-4 @4xl:grid-cols-2">
<CreditsTile class="border-0" :frozen="isInactive" />
<!-- Member snapshot tile (hidden while the plan is lapsed) -->
<div
v-if="canManageBilling && !isInactive"
class="flex flex-col gap-3 rounded-xl bg-modal-panel-background px-6 py-5"
>
<Tabs v-model="snapshotView">
<TabsList>
<TabsTrigger value="top">
{{ $t('workspacePanel.overview.snapshot.topSpenders') }}
</TabsTrigger>
<TabsTrigger value="recent">
{{ $t('workspacePanel.overview.snapshot.recentActivity') }}
</TabsTrigger>
</TabsList>
</Tabs>
<div
v-if="snapshotEmptyMessage"
class="flex flex-1 flex-col items-center justify-center gap-3 py-6 text-center"
>
<i class="icon-[lucide--coins] size-6 text-muted-foreground" />
<p class="m-0 text-sm text-base-foreground">
{{ snapshotEmptyMessage }}
</p>
</div>
<div
v-else
ref="snapshotContainer"
class="min-h-0 flex-1 overflow-hidden"
>
<Table>
<TableHeader>
<TableRow
class="hover:bg-transparent [&>th]:h-9 [&>th]:border-b [&>th]:border-interface-stroke/60"
>
<TableHead>
{{ $t('workspacePanel.overview.snapshot.user') }}
</TableHead>
<TableHead>
{{ $t('workspacePanel.overview.snapshot.lastActivity') }}
</TableHead>
<TableHead class="text-right">
<span class="inline-flex items-center gap-1">
<i class="icon-[lucide--coins] size-4" />
{{ $t('workspacePanel.overview.snapshot.creditsUsed') }}
</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="row in snapshotRows"
:key="row.userName"
class="hover:bg-transparent [&:last-child>td]:border-b-0 [&>td]:border-b [&>td]:border-interface-stroke/20"
>
<TableCell>
<div class="flex items-center gap-2">
<span
class="flex size-5 shrink-0 items-center justify-center rounded-full"
:style="{ backgroundColor: row.color }"
>
<span class="text-2xs font-bold text-base-foreground">
{{ row.userName.charAt(0).toUpperCase() }}
</span>
</span>
<span class="text-sm text-base-foreground">
{{ row.userName }}
</span>
</div>
</TableCell>
<TableCell class="text-sm text-muted-foreground tabular-nums">
{{ row.lastActivity }}
</TableCell>
<TableCell
class="text-right text-sm text-base-foreground tabular-nums"
>
{{ row.credits.toLocaleString() }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<Button
variant="tertiary"
size="lg"
class="mt-auto w-full"
@click="handleSeeMore"
>
{{ $t('workspacePanel.overview.seeMore') }}
</Button>
</div>
</div>
</div>
<!-- Credit auto-reload; frozen alongside the tile whenever the plan can't
spend — lapsed or paused. -->
<AutoReloadSection
v-if="canManageBilling"
:frozen="isInactive || isPaused"
/>
<!-- mt-auto floats the footer to the panel's bottom edge; pb-6 (matching
the other tabs) keeps it level with their footers. -->
<div
class="mt-auto flex shrink-0 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground @2xl:h-8"
>
<a
:href="learnMoreUrl"
target="_blank"
rel="noopener noreferrer"
class="flex cursor-pointer items-center gap-1 text-muted-foreground no-underline transition-colors hover:text-base-foreground"
>
<i class="icon-[lucide--circle-help] size-4" />
{{ $t('workspacePanel.overview.learnMore') }}
</a>
<a
:href="partnerNodesPricingUrl"
target="_blank"
rel="noopener noreferrer"
class="flex cursor-pointer items-center gap-1 text-muted-foreground no-underline transition-colors hover:text-base-foreground"
>
<i class="icon-[lucide--circle-help] size-4" />
{{ $t('workspacePanel.overview.pricingTable') }}
</a>
<button
class="flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 font-[inherit] text-sm text-muted-foreground transition-colors hover:text-base-foreground"
@click="openSupport"
>
<i class="icon-[lucide--message-circle] size-4" />
{{ $t('workspacePanel.overview.messageSupport') }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { MenuItem } from 'primevue/menuitem'
import DropdownMenu from '@/components/common/DropdownMenu.vue'
import Button from '@/components/ui/button/Button.vue'
import Table from '@/components/ui/table/Table.vue'
import TableBody from '@/components/ui/table/TableBody.vue'
import TableCell from '@/components/ui/table/TableCell.vue'
import TableHead from '@/components/ui/table/TableHead.vue'
import TableHeader from '@/components/ui/table/TableHeader.vue'
import TableRow from '@/components/ui/table/TableRow.vue'
import Tabs from '@/components/ui/tabs/Tabs.vue'
import TabsList from '@/components/ui/tabs/TabsList.vue'
import TabsTrigger from '@/components/ui/tabs/TabsTrigger.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useExternalLink } from '@/composables/useExternalLink'
import { useSettingsNavigation } from '@/platform/settings/composables/useSettingsNavigation'
import CreditsTile from '@/platform/cloud/subscription/components/CreditsTile.vue'
import AutoReloadSection from '@/platform/workspace/components/dialogs/settings/AutoReloadSection.vue'
import { buildSupportUrl } from '@/platform/support/config'
import { useAutoPageSize } from '@/platform/workspace/composables/useAutoPageSize'
import { requestMembersSort } from '@/platform/workspace/composables/useMembersPanel'
import { useResubscribe } from '@/platform/workspace/composables/useResubscribe'
import { useTeamPlan } from '@/platform/workspace/composables/useTeamPlan'
import { useWorkspaceOverview } from '@/platform/workspace/composables/useWorkspaceOverview'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useDialogService } from '@/services/dialogService'
const emit = defineEmits<{ navigate: [view: 'activity'] }>()
const { t } = useI18n()
// Plan lifecycle actions are for the workspace creator (Owner) only; Admins and
// Members don't see Change plan or the overflow menu.
const {
isOriginalOwner,
isActiveSubscription,
isTeamPlanCancelled,
permissions
} = useWorkspaceUI()
// Members can't manage or view billing details — only the credit balance. Gates
// the plan price, payment/plan actions, snapshot, next invoice, and auto-reload.
const canManageBilling = computed(() => permissions.value.canManageSubscription)
const { isFreeTier, isPaused, subscription } = useBillingContext()
const { showCancelSubscriptionDialog } = useDialogService()
// A team (or enterprise) workspace whose plan has lapsed shows the reactivation
// state in place of the live plan header, snapshot, and auto-reload.
const { hasLapsedTeamPlan: isInactive } = useTeamPlan()
const { isResubscribing, handleResubscribe } = useResubscribe()
const { buildDocsUrl, docsPaths } = useExternalLink()
const learnMoreUrl = buildDocsUrl('/get_started/cloud', {
includeLocale: true
})
const partnerNodesPricingUrl = buildDocsUrl(docsPaths.partnerNodesPricing, {
includeLocale: true
})
const { userEmail, resolvedUserInfo } = useCurrentUser()
function openSupport() {
const url = buildSupportUrl({
userEmail: userEmail.value,
userId: resolvedUserInfo.value?.id
})
window.open(url, '_blank', 'noopener,noreferrer')
}
const canCancelPlan = computed(
() =>
isOriginalOwner.value &&
isActiveSubscription.value &&
!isTeamPlanCancelled.value &&
!isFreeTier.value
)
const planMenuEntries = computed<MenuItem[]>(() =>
canCancelPlan.value
? [
{
label: t('subscription.cancelPlan'),
command: () =>
void showCancelSubscriptionDialog(
subscription.value?.endDate ?? undefined
)
}
]
: []
)
const { plan, topSpenders, recentActivity } = useWorkspaceOverview()
const { navigateToPanel } = useSettingsNavigation()
// The credits tile dictates the row height; the snapshot tile fits as many
// user rows as that height allows, keeping "See more" pinned to the bottom.
const snapshotContainer = ref<HTMLElement | null>(null)
const { pageSize: visibleSnapshotRows } = useAutoPageSize(snapshotContainer, 1)
const snapshotView = ref('top')
const snapshotRows = computed(() =>
(snapshotView.value === 'top'
? topSpenders.value
: recentActivity.value
).slice(0, visibleSnapshotRows.value)
)
// Each tab gets its own empty state: a top-spenders leaderboard of all-zeros
// (a fresh billing cycle) is meaningless, and recent activity can simply have
// no events yet. Returns the message to show, or null when the table has rows.
const snapshotEmptyMessage = computed(() => {
if (snapshotView.value === 'top') {
return topSpenders.value.some((row) => row.credits > 0)
? null
: t('workspacePanel.overview.snapshot.empty.topSpenders')
}
return recentActivity.value.length === 0
? t('workspacePanel.overview.snapshot.empty.recentActivity')
: null
})
// Top spenders → the Members panel pre-sorted by credit usage; Recent activity
// → the Activity tab.
function handleSeeMore() {
if (snapshotView.value === 'recent') {
emit('navigate', 'activity')
return
}
requestMembersSort('credits')
navigateToPanel('workspace-members')
}
</script>

View File

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

View File

@@ -1,95 +0,0 @@
<template>
<div class="flex size-full flex-col">
<header class="mb-6 flex items-center gap-4">
<WorkspaceProfilePic
class="size-12 text-3xl!"
:workspace-name="workspaceName"
/>
<h1 class="text-3xl font-semibold text-base-foreground">
{{ workspaceName }}
</h1>
</header>
<TabsRoot v-model="activeTab">
<TabsList class="flex items-center gap-2 pb-1">
<TabsTrigger
value="plan"
:class="
cn(
tabTriggerBase,
activeTab === 'plan' ? tabTriggerActive : tabTriggerInactive
)
"
>
{{ $t('workspacePanel.tabs.planCredits') }}
</TabsTrigger>
<TabsTrigger
value="members"
:class="
cn(
tabTriggerBase,
activeTab === 'members' ? tabTriggerActive : tabTriggerInactive
)
"
>
{{
showMembersTabCount
? $t('workspacePanel.tabs.membersCount', {
count: members.length
})
: $t('workspacePanel.members.header')
}}
</TabsTrigger>
</TabsList>
<TabsContent value="plan" class="mt-4">
<SubscriptionPanelContentWorkspace />
</TabsContent>
<TabsContent value="members" class="mt-4">
<MembersPanelContent :key="workspaceRole" />
</TabsContent>
</TabsRoot>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref } from 'vue'
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
import MembersPanelContent from '@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue'
import SubscriptionPanelContentWorkspace from '@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@comfyorg/tailwind-utils'
const tabTriggerBase =
'flex items-center justify-center shrink-0 px-2.5 py-2 text-sm rounded-lg cursor-pointer transition-all duration-200 outline-hidden border-none'
const tabTriggerActive =
'bg-interface-menu-component-surface-hovered text-text-primary font-bold'
const tabTriggerInactive =
'bg-transparent text-text-secondary hover:bg-button-hover-surface focus:bg-button-hover-surface'
const { defaultTab = 'plan' } = defineProps<{
defaultTab?: string
}>()
const workspaceStore = useTeamWorkspaceStore()
const { workspaceName, members } = storeToRefs(workspaceStore)
const { fetchMembers, fetchPendingInvites } = workspaceStore
const { workspaceType, workspaceRole } = useWorkspaceUI()
const isPersonalWorkspace = computed(() => workspaceType.value === 'personal')
const activeTab = ref(defaultTab)
// Per design, the tab counts members only when there is more than the owner
const showMembersTabCount = computed(
() => !isPersonalWorkspace.value && members.value.length > 1
)
onMounted(() => {
fetchMembers()
fetchPendingInvites()
})
</script>

View File

@@ -0,0 +1,143 @@
<template>
<div class="flex min-w-0 items-center gap-4">
<div class="group relative size-12 shrink-0">
<WorkspaceProfilePic
class="size-12 rounded-lg text-2xl"
:workspace-name="workspaceName"
:image-url="imageUrl ?? undefined"
/>
<button
v-if="canEdit"
type="button"
class="absolute inset-0 flex cursor-pointer items-center justify-center rounded-lg border-none bg-black/50 opacity-0 transition-opacity group-hover:opacity-100"
:aria-label="$t('workspacePanel.editWorkspaceImage')"
@click="pickImage"
>
<i class="icon-[lucide--pencil] size-4 text-white" />
</button>
<input
ref="fileInputRef"
type="file"
accept="image/*"
class="hidden"
@change="onFileChange"
/>
</div>
<input
v-if="isRenaming"
ref="inputRef"
v-model="draftName"
:maxlength="MAX_NAME_LENGTH"
class="min-w-0 flex-1 appearance-none border-none bg-transparent p-0 font-[inherit] text-2xl font-semibold text-base-foreground outline-none"
@keydown.enter="commit"
@keydown.esc="cancel"
@blur="commit"
/>
<h1
v-else
v-tooltip="
canEdit
? { value: $t('workspacePanel.doubleClickToRename'), showDelay: 300 }
: undefined
"
class="truncate text-2xl font-semibold text-base-foreground"
@dblclick="beginRename"
>
{{ workspaceName }}
</h1>
<span
v-if="isRenaming && remaining <= 10"
class="shrink-0 text-sm text-muted-foreground tabular-nums"
>
{{ $t('workspacePanel.charactersLeft', remaining) }}
</span>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { storeToRefs } from 'pinia'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
import { useWorkspaceRename } from '@/platform/workspace/composables/useWorkspaceRename'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { WORKSPACE_NAME_MAX_LENGTH } from '@/platform/workspace/workspaceConstants'
const { t } = useI18n()
const toast = useToast()
const store = useTeamWorkspaceStore()
const { workspaceName } = storeToRefs(store)
const { uiConfig } = useWorkspaceUI()
const MAX_NAME_LENGTH = WORKSPACE_NAME_MAX_LENGTH
// Renaming is gated to Owner + Admins (and the sole owner of a personal
// workspace); Members never see the affordance.
const canEdit = computed(() => uiConfig.value.showEditWorkspaceMenuItem)
const { isRenaming, startRenaming, stopRenaming } = useWorkspaceRename()
const draftName = ref('')
const inputRef = ref<HTMLInputElement | null>(null)
// A single entry point (double-click here or the "Rename" menu item) flips
// `isRenaming`; seed the draft and focus the field once the input mounts.
watch(isRenaming, (renaming) => {
if (!renaming) return
draftName.value = workspaceName.value
void nextTick(() => {
inputRef.value?.focus()
inputRef.value?.select()
})
})
// Surface the limit only as the user approaches it, to keep the header quiet.
const remaining = computed(() => MAX_NAME_LENGTH - draftName.value.length)
// Client-side only preview (prototype): the picked image is held locally, not
// uploaded or persisted. Resets on reload.
const imageUrl = ref<string | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
function pickImage() {
fileInputRef.value?.click()
}
function onFileChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
const reader = new FileReader()
reader.onload = () => {
imageUrl.value = reader.result as string
}
reader.readAsDataURL(file)
}
function beginRename() {
if (!canEdit.value) return
startRenaming()
}
async function commit() {
if (!isRenaming.value) return
stopRenaming()
const name = draftName.value.trim()
if (!name || name === workspaceName.value) return
try {
await store.updateWorkspaceName(name)
} catch {
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToUpdateWorkspace')
})
}
}
function cancel() {
stopRenaming()
}
</script>

View File

@@ -0,0 +1,49 @@
import type { Ref } from 'vue'
import { onScopeDispose, ref, watch } from 'vue'
const FALLBACK_ROW_HEIGHT = 41
const MIN_ROWS = 5
/**
* Derive a table's rows-per-page from the live height of its scroll container so
* a taller dialog shows more rows instead of leaving empty space. Row and header
* heights are read from the rendered table, so it adapts if the design changes.
*/
export function useAutoPageSize(
containerRef: Ref<HTMLElement | null>,
min: number = MIN_ROWS
) {
const pageSize = ref(min)
function measure() {
const container = containerRef.value
if (!container) return
// Use fractional (getBoundingClientRect) heights, not the integer
// offsetHeight: truncating each row's height makes the floor below think an
// extra partial row fits, which overflows the container by a few pixels and
// shows a scrollbar. Fractional heights keep a not-quite-fitting row out.
const rowHeight =
container.querySelector<HTMLElement>('tbody tr')?.getBoundingClientRect()
.height || FALLBACK_ROW_HEIGHT
const headerHeight =
container.querySelector<HTMLElement>('thead')?.getBoundingClientRect()
.height ?? 0
const fit = Math.floor((container.clientHeight - headerHeight) / rowHeight)
pageSize.value = Math.max(min, fit)
}
let observer: ResizeObserver | null = null
watch(
containerRef,
(el) => {
observer?.disconnect()
if (!el) return
observer = new ResizeObserver(() => measure())
observer.observe(el)
},
{ immediate: true }
)
onScopeDispose(() => observer?.disconnect())
return { pageSize }
}

View File

@@ -0,0 +1,176 @@
import { computed, reactive } from 'vue'
import { creditsToCents } from '@/base/credits/comfyCredits'
// Prototype: there is no auto-reload API yet, so the config is a client-side
// singleton. The initial scenario is seeded from a dev-harness key; the dialog
// and the header toggle mutate it live.
type AutoReloadScenario =
| 'notset'
| 'nobudget'
| 'healthy'
| 'nearlimit'
| 'paused'
| 'off'
const SCENARIOS: readonly AutoReloadScenario[] = [
'notset',
'nobudget',
'healthy',
'nearlimit',
'paused',
'off'
]
const HARNESS_KEY = 'cbm.autoReload'
interface AutoReloadConfig {
configured: boolean
enabled: boolean
thresholdCredits: number
reloadCredits: number
// null = no monthly budget set
monthlyBudgetCents: number | null
spentThisCycleCents: number
lastReload: { date: Date; credits: number } | null
}
function daysAgo(n: number): Date {
const d = new Date()
d.setDate(d.getDate() - n)
return d
}
function resetDate(): Date {
const d = new Date()
d.setDate(d.getDate() + 20)
return d
}
const DEFAULT_RELOAD = { thresholdCredits: 1000, reloadCredits: 5000 }
const BUDGET_CENTS = 50_000 // $500
const lastReload = () => ({ date: daysAgo(3), credits: 5000 })
function scenarioConfig(scenario: AutoReloadScenario): AutoReloadConfig {
const base: AutoReloadConfig = {
configured: true,
enabled: true,
...DEFAULT_RELOAD,
monthlyBudgetCents: BUDGET_CENTS,
spentThisCycleCents: 4800, // $48
lastReload: lastReload()
}
switch (scenario) {
case 'notset':
return { ...base, configured: false, lastReload: null }
case 'nobudget':
return { ...base, monthlyBudgetCents: null }
case 'nearlimit':
return { ...base, spentThisCycleCents: 47_600 } // ~1 reload left
case 'paused':
return { ...base, spentThisCycleCents: BUDGET_CENTS } // budget exhausted
case 'off':
return { ...base, enabled: false }
case 'healthy':
default:
return base
}
}
function initialScenario(): AutoReloadScenario {
try {
const stored = localStorage.getItem(
HARNESS_KEY
) as AutoReloadScenario | null
if (stored && SCENARIOS.includes(stored)) return stored
} catch {
/* ignore */
}
return 'healthy'
}
const config = reactive<AutoReloadConfig>(scenarioConfig(initialScenario()))
const cycleResetDate = resetDate()
// Dev bridge: the billing-mock harness dispatches this to swap scenarios live,
// so flipping the picker updates the tile in place instead of reloading the page.
if (typeof window !== 'undefined') {
window.addEventListener('cbm:autoReload', (event) => {
const scenario = (event as CustomEvent<AutoReloadScenario>).detail
if (SCENARIOS.includes(scenario)) {
Object.assign(config, scenarioConfig(scenario))
}
})
}
export function useAutoReload() {
const isConfigured = computed(() => config.configured)
const isEnabled = computed(() => config.enabled)
const hasBudget = computed(() => config.monthlyBudgetCents != null)
const reloadCostCents = computed(() => creditsToCents(config.reloadCredits))
const budgetLeftCents = computed(() =>
config.monthlyBudgetCents == null
? 0
: Math.max(0, config.monthlyBudgetCents - config.spentThisCycleCents)
)
const budgetUsedFraction = computed(() =>
config.monthlyBudgetCents != null && config.monthlyBudgetCents > 0
? Math.min(1, config.spentThisCycleCents / config.monthlyBudgetCents)
: 0
)
const reloadsLeft = computed(() =>
hasBudget.value
? Math.floor(budgetLeftCents.value / reloadCostCents.value)
: null
)
// Budget drained while still enabled → auto-reload can't fire, so it's paused.
const isPaused = computed(
() => config.enabled && hasBudget.value && budgetLeftCents.value <= 0
)
// One reload (or none) of headroom left before the budget pauses it.
const isWarning = computed(
() =>
config.enabled &&
hasBudget.value &&
!isPaused.value &&
(reloadsLeft.value ?? Infinity) <= 1
)
function setEnabled(value: boolean) {
config.enabled = value
}
function save(next: {
thresholdCredits: number
reloadCredits: number
monthlyBudgetCents: number | null
}) {
config.configured = true
config.enabled = true
config.thresholdCredits = next.thresholdCredits
config.reloadCredits = next.reloadCredits
config.monthlyBudgetCents = next.monthlyBudgetCents
}
return {
config,
cycleResetDate,
isConfigured,
isEnabled,
hasBudget,
reloadCostCents,
budgetLeftCents,
budgetUsedFraction,
reloadsLeft,
isPaused,
isWarning,
setEnabled,
save
}
}

View File

@@ -254,6 +254,7 @@ const mockShowChangeMemberRoleDialog = vi.fn()
const mockShowSubscriptionDialog = vi.fn()
const mockShowInviteMemberDialog = vi.fn()
const mockShowInviteMemberUpsellDialog = vi.fn()
const mockShowMemberLimitDialog = vi.fn()
const {
mockMembers,
@@ -366,6 +367,9 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: mockIsActiveSubscription,
subscription: mockSubscription,
balance: { value: null },
renewalDate: { value: null },
fetchBalance: vi.fn(),
getMaxSeats: (tierKey: string) => {
const seats: Record<string, number> = {
free: 1,
@@ -391,7 +395,8 @@ vi.mock('@/services/dialogService', () => ({
showRevokeInviteDialog: mockShowRevokeInviteDialog,
showChangeMemberRoleDialog: mockShowChangeMemberRoleDialog,
showInviteMemberDialog: mockShowInviteMemberDialog,
showInviteMemberUpsellDialog: mockShowInviteMemberUpsellDialog
showInviteMemberUpsellDialog: mockShowInviteMemberUpsellDialog,
showMemberLimitDialog: mockShowMemberLimitDialog
})
}))
@@ -593,13 +598,13 @@ describe('useMembersPanel', () => {
const roleItems = items[0].items ?? []
expect(roleItems.map((i) => i.label)).toEqual([
'workspaceSwitcher.roleOwner',
'workspaceSwitcher.roleAdmin',
'workspaceSwitcher.roleMember'
])
expect(roleItems.map((i) => i.checked)).toEqual([false, true])
})
it('checks Owner for owner rows', async () => {
it('checks Admin for owner-role rows', async () => {
const panel = await setup()
const items = panel.memberMenuItems(createMember({ role: 'owner' }))
const roleItems = items[0].items ?? []
@@ -706,14 +711,15 @@ describe('useMembersPanel', () => {
expect(mockShowInviteMemberDialog).not.toHaveBeenCalled()
})
it('disables the invite button at the member cap (30)', async () => {
it('opens the member-limit dialog at the member cap (30)', async () => {
mockTotalMemberSlots.value = 30
const panel = await setup()
expect(panel.isInviteDisabled.value).toBe(true)
expect(panel.isInviteDisabled.value).toBe(false)
expect(panel.inviteTooltip.value).toBe(
'workspacePanel.inviteLimitReached'
)
panel.handleInviteMember()
expect(mockShowMemberLimitDialog).toHaveBeenCalled()
expect(mockShowInviteMemberDialog).not.toHaveBeenCalled()
})
@@ -724,10 +730,12 @@ describe('useMembersPanel', () => {
expect(panel.inviteTooltip.value).toBeNull()
})
it('disables the invite button at the flat backend member cap', async () => {
it('opens the member-limit dialog at the flat backend member cap', async () => {
mockIsInviteLimitReached.value = true
const panel = await setup()
expect(panel.isInviteDisabled.value).toBe(true)
expect(panel.isInviteDisabled.value).toBe(false)
panel.handleInviteMember()
expect(mockShowMemberLimitDialog).toHaveBeenCalled()
})
it('disables the invite button when not on a team plan', async () => {

View File

@@ -5,6 +5,7 @@ import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { WorkspaceRole } from '@/platform/workspace/api/workspaceApi'
import { useTeamPlan } from '@/platform/workspace/composables/useTeamPlan'
@@ -20,15 +21,44 @@ import {
import { useDialogService } from '@/services/dialogService'
type ActiveView = 'active' | 'pending'
type SortField = 'inviteDate' | 'expiryDate' | 'role'
type SortField =
| 'email'
| 'role'
| 'lastActivity'
| 'credits'
| 'inviteDate'
| 'expiryDate'
type SortDirection = 'asc' | 'desc'
// One-shot sort applied the next time the Members panel mounts, so other panels
// can deep-link in with a preset ordering (e.g. Overview "Top spenders").
const pendingSort = ref<SortField | null>(null)
export function requestMembersSort(field: 'credits' | 'lastActivity') {
pendingSort.value = field
}
export function sortMembers(
members: WorkspaceMember[],
currentUserEmail: string | null,
sortDirection: SortDirection,
originalOwnerId: string | null = null
originalOwnerId: string | null = null,
sortField: SortField = 'role'
): WorkspaceMember[] {
const dir = sortDirection === 'asc' ? 1 : -1
if (sortField === 'email') {
return [...members].sort((a, b) => dir * a.name.localeCompare(b.name))
}
if (sortField === 'lastActivity') {
const at = (m: WorkspaceMember) => m.lastActivity?.getTime() ?? 0
return [...members].sort((a, b) => dir * (at(a) - at(b)))
}
if (sortField === 'credits') {
const used = (m: WorkspaceMember) => m.creditsUsedThisMonth ?? 0
return [...members].sort((a, b) => dir * (used(a) - used(b)))
}
// Default (role) ordering pins the creator, then groups by role, then recency.
return [...members].sort((a, b) => {
const aIsOriginalOwner = a.id === originalOwnerId
const bIsOriginalOwner = b.id === originalOwnerId
@@ -97,7 +127,8 @@ export function useMembersPanel() {
showRevokeInviteDialog,
showChangeMemberRoleDialog,
showInviteMemberDialog,
showInviteMemberUpsellDialog
showInviteMemberUpsellDialog,
showMemberLimitDialog
} = useDialogService()
const workspaceStore = useTeamWorkspaceStore()
const {
@@ -112,11 +143,14 @@ export function useMembersPanel() {
const { permissions, uiConfig } = useWorkspaceUI()
const { isOnTeamPlan, isCancelled, hasLapsedTeamPlan } = useTeamPlan()
const subscriptionDialog = useSubscriptionDialog()
const { fetchBalance } = useBillingContext()
// The team plan caps members at a flat MAX_WORKSPACE_MEMBERS, independent of
// the subscription tier.
const maxSeats = computed(() => MAX_WORKSPACE_MEMBERS)
const memberCount = computed(() => members.value.length)
const hasMultipleMembers = computed(() => members.value.length > 1)
const showSearch = computed(
@@ -138,16 +172,16 @@ export function useMembersPanel() {
() => isInviteLimitReached.value || totalMemberSlots.value >= maxSeats.value
)
// Invite is allowed only on an active (non-cancelled) team plan that is under
// the member cap.
// Invite stays enabled at the seat cap so the button can surface the
// "at the member limit" dialog; only an inactive/cancelled plan disables it.
const isInviteDisabled = computed(
() => !isOnTeamPlan.value || isCancelled.value || isMemberLimitReached.value
() => !isOnTeamPlan.value || isCancelled.value
)
const inviteTooltip = computed(() => {
if (!isOnTeamPlan.value) return null
if (!isMemberLimitReached.value) return null
return t('workspacePanel.inviteLimitReached', { count: maxSeats.value })
return t('workspacePanel.inviteLimitReached')
})
function handleInviteMember() {
@@ -155,7 +189,11 @@ export function useMembersPanel() {
void showInviteMemberUpsellDialog()
return
}
if (isCancelled.value || isMemberLimitReached.value) return
if (isCancelled.value) return
if (isMemberLimitReached.value) {
void showMemberLimitDialog()
return
}
void showInviteMemberDialog()
}
@@ -170,8 +208,9 @@ export function useMembersPanel() {
const searchQuery = ref('')
const activeView = ref<ActiveView>('active')
const sortField = ref<SortField>('inviteDate')
const sortField = ref<SortField>(pendingSort.value ?? 'inviteDate')
const sortDirection = ref<SortDirection>('desc')
pendingSort.value = null
function roleMenuItem(
member: WorkspaceMember,
@@ -190,7 +229,7 @@ export function useMembersPanel() {
{
label: t('workspacePanel.members.actions.changeRole'),
items: [
roleMenuItem(member, 'owner', t('workspaceSwitcher.roleOwner')),
roleMenuItem(member, 'owner', t('workspaceSwitcher.roleAdmin')),
roleMenuItem(member, 'member', t('workspaceSwitcher.roleMember'))
]
},
@@ -215,13 +254,14 @@ export function useMembersPanel() {
searched,
userEmail.value ?? null,
sortDirection.value,
originalOwnerId.value
originalOwnerId.value,
sortField.value
)
})
// Built once per member list rather than per row on every render, so an
// unrelated re-render (e.g. typing in the search box) doesn't rebuild every
// row's menu and churn MemberListItem's props.
// row's menu and churn MemberTableRow's props.
const memberMenus = computed(
() => new Map(filteredMembers.value.map((m) => [m.id, memberMenuItems(m)]))
)
@@ -286,9 +326,11 @@ export function useMembersPanel() {
sortField,
sortDirection,
maxSeats,
memberCount,
isOnTeamPlan,
hasLapsedTeamPlan,
hasMultipleMembers,
fetchBalance,
showSearch,
showViewTabs,
showInviteButton,

View File

@@ -0,0 +1,122 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { PartnerNode } from '@/platform/workspace/api/partnerNodesApi'
import { partnerNodesApi } from '@/platform/workspace/api/partnerNodesApi'
import { usePartnerNodes } from '@/platform/workspace/composables/usePartnerNodes'
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key })
}))
const mockToastAdd = vi.fn()
vi.mock('primevue/usetoast', () => ({
useToast: () => ({ add: mockToastAdd })
}))
vi.mock('@/platform/workspace/api/partnerNodesApi', () => ({
partnerNodesApi: {
list: vi.fn(),
setEnabled: vi.fn(),
setEnabledBulk: vi.fn(),
setAutoEnableNew: vi.fn()
}
}))
function node(overrides: Partial<PartnerNode> = {}): PartnerNode {
return {
id: 'pn-1',
name: 'Anthropic Claude',
partner: 'Anthropic',
last_modified: null,
enabled: true,
...overrides
}
}
const sampleNodes: PartnerNode[] = [
node({ id: 'a', name: 'Zeta Node', partner: 'BFL', enabled: true }),
node({ id: 'b', name: 'Alpha Node', partner: 'Anthropic', enabled: false }),
node({ id: 'c', name: 'Beta Node', partner: 'BFL', enabled: true })
]
async function setupLoaded() {
vi.mocked(partnerNodesApi.list).mockResolvedValue({
partner_nodes: sampleNodes.map((n) => ({ ...n })),
auto_enable_new: true
})
const pn = usePartnerNodes()
await pn.fetch()
return pn
}
describe('usePartnerNodes', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('loads nodes and the auto-enable default', async () => {
const pn = await setupLoaded()
expect(pn.nodes.value).toHaveLength(3)
expect(pn.autoEnableNew.value).toBe(true)
})
it('sorts by name ascending by default and toggles direction', async () => {
const pn = await setupLoaded()
expect(pn.filteredNodes.value.map((n) => n.name)).toEqual([
'Alpha Node',
'Beta Node',
'Zeta Node'
])
pn.toggleSort('name')
expect(pn.filteredNodes.value.map((n) => n.name)).toEqual([
'Zeta Node',
'Beta Node',
'Alpha Node'
])
})
it('filters by search across name and partner', async () => {
const pn = await setupLoaded()
pn.searchQuery.value = 'anthropic'
expect(pn.filteredNodes.value.map((n) => n.id)).toEqual(['b'])
})
it('optimistically toggles a node and calls the api', async () => {
const pn = await setupLoaded()
const target = pn.nodes.value.find((n) => n.id === 'b')!
await pn.setEnabled(target, true)
expect(pn.nodes.value.find((n) => n.id === 'b')!.enabled).toBe(true)
expect(partnerNodesApi.setEnabled).toHaveBeenCalledWith('b', true)
})
it('reverts and toasts when a toggle fails', async () => {
const pn = await setupLoaded()
vi.mocked(partnerNodesApi.setEnabled).mockRejectedValueOnce(new Error('x'))
const target = pn.nodes.value.find((n) => n.id === 'a')!
await pn.setEnabled(target, false)
expect(pn.nodes.value.find((n) => n.id === 'a')!.enabled).toBe(true)
expect(mockToastAdd).toHaveBeenCalled()
})
it('bulk-toggles the current selection and keeps it selected', async () => {
const pn = await setupLoaded()
pn.toggleSelection('a')
pn.toggleSelection('c')
await pn.setSelectedEnabled(false)
expect(pn.nodes.value.find((n) => n.id === 'a')!.enabled).toBe(false)
expect(pn.nodes.value.find((n) => n.id === 'c')!.enabled).toBe(false)
expect(partnerNodesApi.setEnabledBulk).toHaveBeenCalledWith(
['a', 'c'],
false
)
expect(pn.selectedCount.value).toBe(2)
})
it('select-all reflects the filtered set', async () => {
const pn = await setupLoaded()
pn.searchQuery.value = 'BFL'
pn.toggleSelectAll()
expect(pn.selectedCount.value).toBe(2)
expect(pn.allFilteredSelected.value).toBe(true)
})
})

View File

@@ -0,0 +1,188 @@
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { PartnerNode } from '@/platform/workspace/api/partnerNodesApi'
import { partnerNodesApi } from '@/platform/workspace/api/partnerNodesApi'
type SortField = 'name' | 'partner' | 'lastModified'
type SortDirection = 'asc' | 'desc'
function compareNodes(
a: PartnerNode,
b: PartnerNode,
field: SortField,
direction: SortDirection
): number {
const dir = direction === 'asc' ? 1 : -1
if (field === 'lastModified') {
const av = a.last_modified ?? ''
const bv = b.last_modified ?? ''
return av.localeCompare(bv) * dir
}
const key = field === 'partner' ? 'partner' : 'name'
return a[key].localeCompare(b[key]) * dir
}
export function usePartnerNodes() {
const { t } = useI18n()
const toast = useToast()
const nodes = ref<PartnerNode[]>([])
const autoEnableNew = ref(true)
const isLoading = ref(false)
const searchQuery = ref('')
const sortField = ref<SortField>('name')
const sortDirection = ref<SortDirection>('asc')
const selectedIds = ref<Set<string>>(new Set())
const filteredNodes = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
const filtered = nodes.value.filter(
(n) =>
!q ||
n.name.toLowerCase().includes(q) ||
n.partner.toLowerCase().includes(q)
)
return filtered.sort((a, b) =>
compareNodes(a, b, sortField.value, sortDirection.value)
)
})
const selectedCount = computed(() => selectedIds.value.size)
const allFilteredSelected = computed(
() =>
filteredNodes.value.length > 0 &&
filteredNodes.value.every((n) => selectedIds.value.has(n.id))
)
async function fetch() {
isLoading.value = true
try {
const data = await partnerNodesApi.list()
nodes.value = data.partner_nodes
autoEnableNew.value = data.auto_enable_new
} catch {
toast.add({
severity: 'error',
summary: t('workspacePanel.partnerNodes.loadError')
})
} finally {
isLoading.value = false
}
}
function toggleSort(field: SortField) {
if (sortField.value === field) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
} else {
sortField.value = field
sortDirection.value = 'asc'
}
}
function applyEnabled(ids: string[], enabled: boolean) {
const idSet = new Set(ids)
const now = new Date().toISOString()
nodes.value = nodes.value.map((n) =>
idSet.has(n.id) ? { ...n, enabled, last_modified: now } : n
)
}
async function setEnabled(node: PartnerNode, enabled: boolean) {
const { enabled: prevEnabled, last_modified: prevModified } = node
applyEnabled([node.id], enabled)
try {
await partnerNodesApi.setEnabled(node.id, enabled)
} catch {
nodes.value = nodes.value.map((n) =>
n.id === node.id
? { ...n, enabled: prevEnabled, last_modified: prevModified }
: n
)
toast.add({
severity: 'error',
summary: t('workspacePanel.partnerNodes.updateError')
})
}
}
async function setSelectedEnabled(enabled: boolean) {
const ids = [...selectedIds.value]
if (ids.length === 0) return
const previous = new Map(
nodes.value.map((n) => [
n.id,
{ enabled: n.enabled, last_modified: n.last_modified }
])
)
applyEnabled(ids, enabled)
try {
// Keep the selection after a bulk toggle so the user can flip it again.
await partnerNodesApi.setEnabledBulk(ids, enabled)
} catch {
nodes.value = nodes.value.map((n) =>
previous.has(n.id) ? { ...n, ...previous.get(n.id)! } : n
)
toast.add({
severity: 'error',
summary: t('workspacePanel.partnerNodes.updateError')
})
}
}
async function setAutoEnableNew(value: boolean) {
const previous = autoEnableNew.value
autoEnableNew.value = value
try {
await partnerNodesApi.setAutoEnableNew(value)
} catch {
autoEnableNew.value = previous
toast.add({
severity: 'error',
summary: t('workspacePanel.partnerNodes.updateError')
})
}
}
function toggleSelection(id: string) {
const next = new Set(selectedIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
selectedIds.value = next
}
function toggleSelectAll() {
if (allFilteredSelected.value) {
clearSelection()
return
}
selectedIds.value = new Set(filteredNodes.value.map((n) => n.id))
}
function clearSelection() {
selectedIds.value = new Set()
}
return {
nodes,
autoEnableNew,
isLoading,
searchQuery,
sortField,
sortDirection,
selectedIds,
selectedCount,
allFilteredSelected,
filteredNodes,
fetch,
toggleSort,
setEnabled,
setSelectedEnabled,
setAutoEnableNew,
toggleSelection,
toggleSelectAll,
clearSelection
}
}

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { useWorkspaceActivity } from './useWorkspaceActivity'
describe('useWorkspaceActivity', () => {
it('paginates to the requested page size', () => {
const { itemsPerPage, pagedItems } = useWorkspaceActivity('', 11)
expect(pagedItems.value).toHaveLength(itemsPerPage.value)
})
it('aggregates per-user totals and latest activity for the hover card', () => {
const { pagedItems, userSummaries } = useWorkspaceActivity('', 11)
const sample = pagedItems.value[0]
const summary = userSummaries.value.get(sample.userName)
expect(summary).toBeDefined()
// The rollup covers every event for the user, so it is at least one row.
expect(summary!.totalCredits).toBeGreaterThanOrEqual(sample.credits)
expect(summary!.lastActivity.getTime()).toBeGreaterThanOrEqual(
sample.date.getTime()
)
})
})

View File

@@ -0,0 +1,196 @@
import type { MaybeRefOrGetter } from 'vue'
import { computed, ref, toValue, watch } from 'vue'
export interface ActivityEvent {
id: string
date: Date
userName: string
eventType: string
detail: string
credits: number
/** The partner node used, for 'Partner node usage' events. */
partnerNode?: string
/** True for credit inflows (auto-reload, top-up) vs. usage outflows. */
credited?: boolean
}
export interface UserSummary {
totalCredits: number
lastActivity: Date
}
// Prototype mock: there is no usage-activity endpoint yet, so the event list is
// generated client-side and paginated in the browser.
const USERS = ['Yuta', 'Jane', 'Rob', 'Min', 'Alice', 'Priya', 'Diego']
const EVENT_TYPES = ['Cloud workflow', 'Partner node usage']
const DETAILS = ['1m 4s', '13m 4s', '59s', '1m 1s', '56s', '2m 12s', '48s']
const CREDITS = [514, 15, 512, 520, 513, 516, 88]
const PARTNER_NODES = [
'Nano Banana Pro',
'Kling Video',
'Flux.1 Kontext [pro] Image',
'OpenAI GPT Image',
'Runway Gen-4 Image',
'Luma Ray 2',
'ByteDance Seedance 2.0',
'Ideogram V4'
]
const HOUR_MS = 60 * 60 * 1000
const BASE = new Date('2026-02-25T18:30:00').getTime()
// Credit inflows (auto-reload / manual top-up) are workspace-level, not tied to a
// user — they read as '—' in the User column and carry no duration.
const INFLOWS: Omit<ActivityEvent, 'id'>[] = [
{
date: new Date(BASE - 20 * HOUR_MS),
userName: '',
eventType: 'Auto-reload',
detail: '',
credits: 5000,
credited: true
},
{
date: new Date(BASE - 96 * HOUR_MS),
userName: '',
eventType: 'Additional credits added',
detail: '',
credits: 20000,
credited: true
},
{
date: new Date(BASE - 190 * HOUR_MS),
userName: '',
eventType: 'Auto-reload',
detail: '',
credits: 5000,
credited: true
}
]
function mockActivity(): ActivityEvent[] {
const usage = Array.from({ length: 44 }, (_, i) => {
const eventType = EVENT_TYPES[i % EVENT_TYPES.length]
return {
id: `act-${i}`,
date: new Date(BASE - i * 7 * HOUR_MS),
userName: USERS[i % USERS.length],
eventType,
detail: DETAILS[i % DETAILS.length],
credits: CREDITS[i % CREDITS.length],
partnerNode:
eventType === 'Partner node usage'
? PARTNER_NODES[i % PARTNER_NODES.length]
: undefined
}
})
const inflows = INFLOWS.map((event, i) => ({ ...event, id: `act-in-${i}` }))
return [...usage, ...inflows]
}
export type ActivitySortField =
| 'date'
| 'user'
| 'eventType'
| 'detail'
| 'credits'
export function useWorkspaceActivity(
search: MaybeRefOrGetter<string>,
pageSize: MaybeRefOrGetter<number>,
selfName: MaybeRefOrGetter<string | null> = null
) {
const all = mockActivity()
const page = ref(1)
const perPage = computed(() => Math.max(1, toValue(pageSize)))
const sortField = ref<ActivitySortField>('date')
const sortDirection = ref<'asc' | 'desc'>('desc')
// Members only see their own usage. There's no per-user endpoint in this
// prototype, so present the mock events as the member's own history.
const base = computed<ActivityEvent[]>(() => {
const self = toValue(selfName)
if (!self) return all
// Relabel usage to the member; credit inflows stay workspace-level ('—').
return all.map((event) =>
event.credited ? event : { ...event, userName: self }
)
})
const filtered = computed(() => {
const q = toValue(search).trim().toLowerCase()
if (!q) return base.value
return base.value.filter(
(event) =>
event.userName.toLowerCase().includes(q) ||
event.eventType.toLowerCase().includes(q)
)
})
const sorted = computed(() => {
const dir = sortDirection.value === 'asc' ? 1 : -1
return [...filtered.value].sort((a, b) => {
if (sortField.value === 'credits') return dir * (a.credits - b.credits)
if (sortField.value === 'user')
return dir * a.userName.localeCompare(b.userName)
if (sortField.value === 'eventType')
return dir * a.eventType.localeCompare(b.eventType)
if (sortField.value === 'detail')
return dir * a.detail.localeCompare(b.detail)
return dir * (a.date.getTime() - b.date.getTime())
})
})
const total = computed(() => filtered.value.length)
const pagedItems = computed(() => {
const start = (page.value - 1) * perPage.value
return sorted.value.slice(start, start + perPage.value)
})
function toggleSort(field: ActivitySortField) {
if (sortField.value === field) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
} else {
sortField.value = field
sortDirection.value = 'desc'
}
}
watch([total, perPage], ([count]) => {
const lastPage = Math.max(1, Math.ceil(count / perPage.value))
if (page.value > lastPage) page.value = lastPage
})
// Per-user rollups behind the User hover card: lifetime credits and the most
// recent event, aggregated across the whole (unpaged) list.
const userSummaries = computed(() => {
const map = new Map<string, UserSummary>()
for (const event of base.value) {
if (event.credited) continue
const existing = map.get(event.userName)
if (!existing) {
map.set(event.userName, {
totalCredits: event.credits,
lastActivity: event.date
})
} else {
existing.totalCredits += event.credits
if (event.date > existing.lastActivity)
existing.lastActivity = event.date
}
}
return map
})
return {
page,
total,
itemsPerPage: perPage,
pagedItems,
sortField,
sortDirection,
toggleSort,
userSummaries
}
}

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import { nextTick, ref } from 'vue'
import { useWorkspaceInvoices } from './useWorkspaceInvoices'
describe('useWorkspaceInvoices', () => {
it('paginates the list to the requested page size', () => {
const { page, total, itemsPerPage, pagedItems } = useWorkspaceInvoices(
'',
11
)
expect(pagedItems.value).toHaveLength(itemsPerPage.value)
const firstIds = pagedItems.value.map((i) => i.id)
page.value = 2
expect(pagedItems.value.map((i) => i.id)).not.toEqual(firstIds)
const lastPage = Math.ceil(total.value / itemsPerPage.value)
page.value = lastPage
expect(pagedItems.value.length).toBeGreaterThan(0)
expect(pagedItems.value.length).toBeLessThanOrEqual(itemsPerPage.value)
})
it('filters by event type and snaps the page back into range', async () => {
const search = ref('')
const { page, total, itemsPerPage, pagedItems } = useWorkspaceInvoices(
search,
11
)
const fullTotal = total.value
page.value = 3
search.value = 'subscription'
await nextTick()
expect(total.value).toBeLessThan(fullTotal)
expect(
pagedItems.value.every((i) => i.eventType === 'Subscription payment')
).toBe(true)
// Page snaps back within range rather than stranding an empty page.
expect(page.value).toBeLessThanOrEqual(
Math.ceil(total.value / itemsPerPage.value)
)
expect(pagedItems.value.length).toBeGreaterThan(0)
})
it('sorts by price ascending and descending', () => {
const { pagedItems, sortDirection, toggleSort } = useWorkspaceInvoices(
'',
11
)
toggleSort('price')
const desc = pagedItems.value.map((i) => i.amountCents)
expect([...desc].sort((a, b) => b - a)).toEqual(desc)
toggleSort('price')
expect(sortDirection.value).toBe('asc')
const asc = pagedItems.value.map((i) => i.amountCents)
expect([...asc].sort((a, b) => a - b)).toEqual(asc)
})
})

View File

@@ -0,0 +1,100 @@
import type { MaybeRefOrGetter } from 'vue'
import { computed, ref, toValue, watch } from 'vue'
export interface Invoice {
id: string
date: Date
eventType: string
amountCents: number
}
// Prototype mock: there is no billing-history endpoint yet, so the invoice
// list is generated client-side and paginated in the browser.
const INVOICE_KINDS: Array<[eventType: string, amountCents: number]> = [
['Subscription payment', 32000],
['Auto-reload', 2400],
['Auto-reload', 2400],
['Additional credit top-up', 10000],
['Auto-reload', 2400],
['Subscription payment', 32000]
]
const DAY_MS = 24 * 60 * 60 * 1000
const BASE = new Date('2026-02-20T18:30:00').getTime()
const NEXT_INVOICE_CENTS = 32000
function mockInvoices(): Invoice[] {
return Array.from({ length: 44 }, (_, i) => {
const [eventType, amountCents] = INVOICE_KINDS[i % INVOICE_KINDS.length]
return {
id: `inv-${i}`,
date: new Date(BASE - i * DAY_MS),
eventType,
amountCents
}
})
}
export type InvoiceSortField = 'date' | 'eventType' | 'price'
export function useWorkspaceInvoices(
search: MaybeRefOrGetter<string>,
pageSize: MaybeRefOrGetter<number>
) {
const all = mockInvoices()
const page = ref(1)
const perPage = computed(() => Math.max(1, toValue(pageSize)))
const sortField = ref<InvoiceSortField>('date')
const sortDirection = ref<'asc' | 'desc'>('desc')
const filtered = computed(() => {
const q = toValue(search).trim().toLowerCase()
if (!q) return all
return all.filter((invoice) => invoice.eventType.toLowerCase().includes(q))
})
const sorted = computed(() => {
const dir = sortDirection.value === 'asc' ? 1 : -1
return [...filtered.value].sort((a, b) => {
if (sortField.value === 'price')
return dir * (a.amountCents - b.amountCents)
if (sortField.value === 'eventType')
return dir * a.eventType.localeCompare(b.eventType)
return dir * (a.date.getTime() - b.date.getTime())
})
})
const total = computed(() => filtered.value.length)
const pagedItems = computed(() => {
const start = (page.value - 1) * perPage.value
return sorted.value.slice(start, start + perPage.value)
})
function toggleSort(field: InvoiceSortField) {
if (sortField.value === field) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
} else {
sortField.value = field
sortDirection.value = 'desc'
}
}
// Filtering or a shorter dialog can shrink the list past the current page;
// snap back into range.
watch([total, perPage], ([count]) => {
const lastPage = Math.max(1, Math.ceil(count / perPage.value))
if (page.value > lastPage) page.value = lastPage
})
return {
page,
total,
itemsPerPage: perPage,
pagedItems,
sortField,
sortDirection,
toggleSort,
nextInvoiceCents: NEXT_INVOICE_CENTS
}
}

View File

@@ -11,7 +11,7 @@ import { useDialogService } from '@/services/dialogService'
* Builds the Plan & Credits overflow-menu model for the workspace subscription
* panel. Visibility and the Delete enable/disable policy are derived from the
* shared useWorkspaceUI state so this menu can't desync with the sibling
* WorkspacePanelContent menu.
* Plan & Credits panel menu.
*/
export function useWorkspaceMenuItems() {
const { t } = useI18n()

View File

@@ -0,0 +1,87 @@
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { userBadgeColor } from '@/platform/workspace/utils/badgeColor'
import { formatRelativeTime } from '@/platform/workspace/utils/relativeTime'
export interface SnapshotRow {
userName: string
color: string
lastActivity: string
credits: number
}
// Upper bound on the snapshot pool; the tile shows as many of these as its
// height allows (see visibleSnapshotRows in WorkspaceOverviewContent).
const SNAPSHOT_SIZE = 6
export function useWorkspaceOverview() {
const { t, d } = useI18n()
const store = useTeamWorkspaceStore()
const { members } = storeToRefs(store)
const { subscription } = useBillingContext()
const nextRenewal = new Date()
nextRenewal.setDate(nextRenewal.getDate() + 20)
const renewalLabel = d(nextRenewal, {
month: 'short',
day: 'numeric',
year: 'numeric'
})
// The enterprise tier relabels the same team layout. 'ENTERPRISE' is a wire
// tier not yet in the generated SubscriptionTier union, hence the cast.
const plan = computed(() => ({
name:
(subscription.value?.tier as string | null) === 'ENTERPRISE'
? 'Enterprise'
: 'Team',
monthlyCredits: 56900,
renewalLabel
}))
function activityLabel(member: WorkspaceMember): string {
if (!member.lastActivity) return '—'
return formatRelativeTime(member.lastActivity, new Date(), {
justNow: t('workspacePanel.members.activity.justNow'),
minutesAgo: (n) => t('workspacePanel.members.activity.minutesAgo', { n }),
hoursAgo: (n) => t('workspacePanel.members.activity.hoursAgo', { n }),
daysAgo: (n) => t('workspacePanel.members.activity.daysAgo', n)
})
}
function toRow(member: WorkspaceMember): SnapshotRow {
return {
userName: member.name,
color: userBadgeColor(member.name || member.email),
lastActivity: activityLabel(member),
credits: member.creditsUsedThisMonth ?? 0
}
}
const topSpenders = computed(() =>
[...members.value]
.sort(
(a, b) => (b.creditsUsedThisMonth ?? 0) - (a.creditsUsedThisMonth ?? 0)
)
.slice(0, SNAPSHOT_SIZE)
.map(toRow)
)
const recentActivity = computed(() =>
[...members.value]
.filter((member) => member.lastActivity)
.sort(
(a, b) =>
(b.lastActivity?.getTime() ?? 0) - (a.lastActivity?.getTime() ?? 0)
)
.slice(0, SNAPSHOT_SIZE)
.map(toRow)
)
return { plan, topSpenders, recentActivity }
}

View File

@@ -0,0 +1,17 @@
import { ref } from 'vue'
// Shared inline-rename state so both the header (double-click) and the
// workspace menu ("Rename") drive the same editing affordance.
const isRenaming = ref(false)
export function useWorkspaceRename() {
function startRenaming() {
isRenaming.value = true
}
function stopRenaming() {
isRenaming.value = false
}
return { isRenaming, startRenaming, stopRenaming }
}

View File

@@ -209,7 +209,7 @@ describe('useWorkspaceUI', () => {
expect(ui.workspaceRole.value).toBe('member')
expect(ui.permissions.value).toMatchObject({
canViewOtherMembers: true,
canViewPendingInvites: false,
canViewPendingInvites: true,
canInviteMembers: false,
canManageInvites: false,
canManageMembers: false,
@@ -220,11 +220,11 @@ describe('useWorkspaceUI', () => {
})
})
it('shows members but hides invite management and uses leave action', async () => {
it('shows members and pending but hides invite management and uses leave action', async () => {
const ui = await loadComposable()
expect(ui.uiConfig.value.showMembersList).toBe(true)
expect(ui.uiConfig.value.showPendingTab).toBe(false)
expect(ui.uiConfig.value.showPendingTab).toBe(true)
expect(ui.uiConfig.value.showEditWorkspaceMenuItem).toBe(false)
expect(ui.uiConfig.value.workspaceMenuAction).toBe('leave')
expect(ui.uiConfig.value.workspaceMenuDisabledTooltip).toBeNull()

View File

@@ -23,6 +23,9 @@ interface WorkspacePermissions {
// commit); only the original owner gets `canManageSubscriptionLifecycle`.
canManageSubscriptionLifecycle: boolean
canTopUp: boolean
// Partner-node governance is workspace-wide and gated to Owner + Admins
// (both hold the backend 'owner' role); Members never see the tab.
canManagePartnerNodes: boolean
}
/** UI configuration for workspace role */
@@ -56,7 +59,8 @@ function getPermissions(
canManageSubscription: true,
// Personal workspace is single-member: the user is the sole owner/creator.
canManageSubscriptionLifecycle: true,
canTopUp: true
canTopUp: true,
canManagePartnerNodes: false
}
}
@@ -71,14 +75,17 @@ function getPermissions(
canAccessWorkspaceMenu: true,
canManageSubscription: true,
canManageSubscriptionLifecycle: isOriginalOwner,
canTopUp: true
canTopUp: true,
canManagePartnerNodes: true
}
}
// member role
return {
canViewOtherMembers: true,
canViewPendingInvites: false,
// Members can see who's been invited (view-only); they still can't
// resend/revoke (canManageInvites) or invite (canInviteMembers).
canViewPendingInvites: true,
canInviteMembers: false,
canManageInvites: false,
canManageMembers: false,
@@ -86,7 +93,8 @@ function getPermissions(
canAccessWorkspaceMenu: true,
canManageSubscription: false,
canManageSubscriptionLifecycle: false,
canTopUp: false
canTopUp: false,
canManagePartnerNodes: false
}
}
@@ -128,7 +136,7 @@ function getUIConfig(
// member role
return {
showMembersList: true,
showPendingTab: false,
showPendingTab: true,
showSearch: true,
showRoleColumn: true,
membersGridCols: 'grid-cols-[1fr_auto]',

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,8 @@ export interface WorkspaceMember {
joinDate: Date
role: 'owner' | 'member'
isOriginalOwner: boolean
lastActivity?: Date | null
creditsUsedThisMonth?: number
}
export interface PendingInvite {
@@ -51,7 +53,11 @@ function mapApiMemberToWorkspaceMember(member: Member): WorkspaceMember {
email: member.email,
joinDate: new Date(member.joined_at),
role: member.role,
isOriginalOwner: member.is_original_owner ?? false
isOriginalOwner: member.is_original_owner ?? false,
lastActivity: member.last_active_at
? new Date(member.last_active_at)
: null,
creditsUsedThisMonth: member.credits_used_this_month ?? 0
}
}
@@ -104,7 +110,7 @@ function setLastWorkspaceId(workspaceId: string): void {
}
const MAX_OWNED_WORKSPACES = 10
export const MAX_WORKSPACE_MEMBERS = 30
export const MAX_WORKSPACE_MEMBERS = 50
const MAX_INIT_RETRIES = 3
const BASE_RETRY_DELAY_MS = 1000

View File

@@ -0,0 +1,21 @@
// Muted, low-saturation badge palette (sampled from the Figma usage palette).
// Dark tones that read against the dark surface with a white monogram.
const BADGE_COLORS = [
'#956252', // terracotta
'#3e465f', // slate indigo
'#424f45', // olive green
'#90646e', // mauve rose
'#6d5a7a', // muted purple
'#4f6b6b', // muted teal
'#7a6a4a', // khaki
'#5a6270' // steel
]
/** Stable muted badge color for a user, keyed by name/email. */
export function userBadgeColor(seed: string): string {
let hash = 0
for (let i = 0; i < seed.length; i++) {
hash = (hash * 31 + seed.charCodeAt(i)) >>> 0
}
return BADGE_COLORS[hash % BADGE_COLORS.length]
}

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { formatRelativeTime } from './relativeTime'
const labels = {
justNow: 'just now',
minutesAgo: (n: number) => `${n} min ago`,
hoursAgo: (n: number) => `${n} hr ago`,
daysAgo: (n: number) => `${n} days ago`
}
const now = new Date('2026-07-03T12:00:00Z')
const ago = (ms: number) => new Date(now.getTime() - ms)
describe('formatRelativeTime', () => {
it('returns "just now" under a minute', () => {
expect(formatRelativeTime(ago(30 * 1000), now, labels)).toBe('just now')
})
it('floors to whole minutes', () => {
expect(formatRelativeTime(ago(6.9 * 60 * 1000), now, labels)).toBe(
'6 min ago'
)
})
it('floors to whole hours', () => {
expect(formatRelativeTime(ago(2 * 60 * 60 * 1000), now, labels)).toBe(
'2 hr ago'
)
})
it('floors to whole days past 24h', () => {
expect(formatRelativeTime(ago(3 * 24 * 60 * 60 * 1000), now, labels)).toBe(
'3 days ago'
)
})
it('clamps future dates to "just now"', () => {
expect(formatRelativeTime(ago(-5000), now, labels)).toBe('just now')
})
})

View File

@@ -0,0 +1,29 @@
const MINUTE_MS = 60 * 1000
const HOUR_MS = 60 * MINUTE_MS
const DAY_MS = 24 * HOUR_MS
interface RelativeTimeLabels {
justNow: string
minutesAgo: (n: number) => string
hoursAgo: (n: number) => string
daysAgo: (n: number) => string
}
/**
* Abbreviated "time ago" label (e.g. "6 min ago", "2 hr ago", "3 days ago"),
* matching the member-list activity column. Copy is injected so callers can
* supply localized, pluralized strings.
*/
export function formatRelativeTime(
date: Date,
now: Date,
labels: RelativeTimeLabels
): string {
const elapsed = Math.max(0, now.getTime() - date.getTime())
if (elapsed < MINUTE_MS) return labels.justNow
if (elapsed < HOUR_MS)
return labels.minutesAgo(Math.floor(elapsed / MINUTE_MS))
if (elapsed < DAY_MS) return labels.hoursAgo(Math.floor(elapsed / HOUR_MS))
return labels.daysAgo(Math.floor(elapsed / DAY_MS))
}

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { roleLabelKey } from '@/platform/workspace/utils/roleLabels'
describe('roleLabelKey', () => {
it('labels the workspace creator as Owner', () => {
expect(roleLabelKey('owner', true)).toBe('workspaceSwitcher.roleOwner')
})
it('labels a non-creator owner-role member as Admin', () => {
expect(roleLabelKey('owner', false)).toBe('workspaceSwitcher.roleAdmin')
})
it('labels a member as Member regardless of creator flag', () => {
expect(roleLabelKey('member', false)).toBe('workspaceSwitcher.roleMember')
expect(roleLabelKey('member', true)).toBe('workspaceSwitcher.roleMember')
})
})

View File

@@ -0,0 +1,23 @@
import type { WorkspaceRole } from '@/platform/workspace/api/workspaceApi'
export type RoleLabelKey =
| 'workspaceSwitcher.roleOwner'
| 'workspaceSwitcher.roleAdmin'
| 'workspaceSwitcher.roleMember'
/**
* Resolves the display label for a member's role.
*
* The backend role is `'owner' | 'member'`, where `'owner'` covers both the
* workspace creator and elevated non-creators. V1 splits those visually:
* the creator keeps "Owner"; every other `'owner'` becomes "Admin".
*/
export function roleLabelKey(
role: WorkspaceRole,
isOriginalOwner: boolean
): RoleLabelKey {
if (role === 'member') return 'workspaceSwitcher.roleMember'
return isOriginalOwner
? 'workspaceSwitcher.roleOwner'
: 'workspaceSwitcher.roleAdmin'
}

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