Compare commits

..

6 Commits

Author SHA1 Message Date
comfydesigner
b0c5775f49 feat: workspace activity ledger + member auditing
Adds the Activity tab to Plan & Credits: a sortable, paginated credit
ledger (auto page-sized to the dialog) with per-user hover-card rollups
and partner-node detail on usage rows; members are scoped to their own
rows, owners and admins see everyone. The Credits-tab snapshot's Recent
activity now deep-links here, and the tab-row search scopes the ledger.
There is no usage-activity endpoint yet — the ledger renders its empty
state until events arrive; the filter/sort/pagination/rollup machinery
is real and ready for the data.
2026-07-10 15:11:30 -07:00
comfydesigner
f5c5597d70 feat: credit auto-reload with a monthly budget
Adds the auto-reload section to the Credits tab: threshold + reload
amount configured through a dialog (with a minimum-amount guard), an
optional monthly budget rendered as a color-coded progress bar (muted,
amber near the cap, red and Paused-badged when exhausted), and a
toggle-first enable sentence. The whole section freezes — dimmed,
forced off — whenever the plan can't spend (paused or lapsed), and
card failures are covered by the shared payment-declined banner rather
than tile-level state. Config is client-side until the auto-reload
endpoint exists; it starts unconfigured and edits don't persist.
2026-07-10 15:10:40 -07:00
comfydesigner
85f9a5347d feat: Plan & Credits settings tabs
Replaces the combined Workspace panel with the dedicated Plan & Credits
entry (keeping the 'workspace' key so deep links land) hosting Credits
and Invoices tabs, role-gated: Invoices is billing-managers only. The
Invoices tab surfaces the upcoming charge with a Full invoice history
link to the Stripe portal — invoice history and downloads live in
Stripe by design; the banner stays hidden until the API exposes the
upcoming amount. On the paused state the shared status banner hosts the
history link instead so banners never stack. Sidebar icons: receipt for
Plan & Credits, users for Members.
2026-07-10 15:08:56 -07:00
comfydesigner
f2d632385b feat: billing lifecycle states across workspace settings
One status-banner slot (BillingStatusBanner) above every workspace
settings surface, rendering by priority: subscription paused / payment
declined / out of credits (amber triangle, action needed) and the plan-
ending notice (muted circle, secondary Reactivate — cancellation was
intentional). Adds the paused subscription status, the Credits tab
header with plan-aware inactive copy (team vs enterprise) wired to
resubscribe, and a member snapshot tile (top spenders / recent
activity from the members store, sized to the dialog via
useAutoPageSize; renders its empty states until the API carries
per-member usage).

CreditsTile: 24px total, Monthly/Yearly cycle bar (annual grants are
front-loaded), inverted Add credits when fully out, dims when paused,
and a frozen mode for lapsed plans. Credit iconography moves to
lucide-coins across credit surfaces.
2026-07-10 15:06:23 -07:00
comfydesigner
fa2e174d81 feat: workspace Members settings panel + identity editing
Restructures workspace member management into its own settings entry:
a Members sidebar panel (table rows with role menu, last-activity and
credits-used columns rendering em-dash/0 until the API carries the
fields, pending-invite rows, Owner/Admin role labels), the workspace
settings header with inline rename (double-click or menu, save-on-blur,
Escape cancels, 30-char cap) and hover image-edit affordance, and the
member-limit flow (count-free copy, Invite enabled at cap surfacing a
request-seats dialog wired to the team-plan request form; seat cap 50
counting pending invites).

The existing combined Workspace panel stays registered so plan and
credit management remains reachable; a follow-up swaps it to the
dedicated Plan & Credits panel. Table primitives (nowrap cells,
divider rows) land here with their first consumer.
2026-07-10 15:04:56 -07:00
comfydesigner
a898e39d20 refactor: extract shared SelectionBar + dialog/menu plumbing
Extracts the floating selection toolbar out of MediaAssetSelectionBar
into a reusable SelectionBar (label + deselect + action slot), gives
DropdownMenu an opt-out modal prop, widens the reka<->PrimeVue dismiss
bridge to popup triggers so closing a menu doesn't dismiss the host
dialog, and bumps SearchInput text to 14px. Groundwork for the
workspace settings panels.
2026-07-10 15:04:09 -07:00
116 changed files with 4552 additions and 2718 deletions

View File

@@ -1,144 +0,0 @@
import type { Page, Route } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type { SettingDialog } from '@e2e/fixtures/components/SettingDialog'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
/**
* Shared scaffolding for the cloud user-secrets (API keys) E2E, alongside the
* sibling cloud mocks (`cloudBillingMocks`, `workspaceMocks`): the stateful
* in-memory `/secrets` backend and the open-the-Secrets-panel helper, so the
* spec files hold only the behavioral flow.
*/
// `/api/features` is the remote-config source. Enabling user secrets is what
// surfaces the Secrets settings panel for a signed-in user.
export const SECRETS_BOOT_FEATURES = {
user_secrets_enabled: true
} satisfies RemoteConfig
// TutorialCompleted suppresses the new-user template browser, whose modal
// overlay (z-1700) would otherwise intercept clicks on the settings dialog.
export const SECRETS_BOOT_SETTINGS = { 'Comfy.TutorialCompleted': true }
interface SecretRecord {
id: string
name: string
provider?: string
created_at: string
updated_at: string
last_used_at?: string
}
interface CreateCapture {
name?: string
provider?: string
secret_value?: string
}
interface SecretsBackend {
/** Bodies received by POST /secrets, in order — for asserting what was sent. */
createRequests: CreateCapture[]
/** Current server-side store — for asserting delete actually removed a row. */
store: SecretRecord[]
}
/**
* Stateful mock of the ingest `/secrets` surface. A single route handler
* branches on path + method so registration order can never make a specific
* path (`/secrets/providers`, `/secrets/:id`) lose to the collection glob.
*
* `providerIds` models entitlement: an entitled account sees runway/gemini,
* a non-entitled account gets an empty list (the server omits them).
*/
export async function mockSecretsBackend(
page: Page,
providerIds: string[]
): Promise<SecretsBackend> {
const backend: SecretsBackend = { createRequests: [], store: [] }
let idSeq = 0
const respondList = (route: Route) =>
route.fulfill(jsonRoute({ data: backend.store }))
await page.route('**/api/secrets**', async (route) => {
const request = route.request()
const { pathname } = new URL(request.url())
const method = request.method()
// The glob `**/api/secrets**` also matches the panel's own lazy-loaded
// source module (`/src/platform/secrets/api/secretsApi.ts`), whose path
// contains the `/api/secrets` substring. Fulfilling that dev-server module
// request with JSON breaks the dynamic import and the panel never mounts.
// Anchor to the start of the pathname so only genuine `/api/secrets…` API
// routes are handled; everything else falls through to the real Vite server.
if (!/^\/api\/secrets(\/|$)/.test(pathname)) {
return route.continue()
}
// GET /secrets/providers — the entitlement-gated provider allowlist.
if (pathname.endsWith('/secrets/providers')) {
return route.fulfill(
jsonRoute({ data: providerIds.map((id) => ({ id })) })
)
}
// /secrets/:id — item routes (only DELETE is exercised by this flow).
const itemMatch = pathname.match(/\/secrets\/([^/]+)$/)
if (itemMatch) {
const id = itemMatch[1]
if (method === 'DELETE') {
backend.store = backend.store.filter((s) => s.id !== id)
return route.fulfill({ status: 204, body: '' })
}
return respondList(route)
}
// /secrets — collection routes.
if (method === 'POST') {
const body = (request.postDataJSON() ?? {}) as CreateCapture
backend.createRequests.push(body)
idSeq += 1
const created: SecretRecord = {
id: `00000000-0000-4000-8000-${String(idSeq).padStart(12, '0')}`,
name: body.name ?? '',
provider: body.provider,
created_at: '2026-07-08T00:00:00Z',
updated_at: '2026-07-08T00:00:00Z'
}
backend.store.push(created)
// Response echoes metadata ONLY — the schema has no secret_value field.
return route.fulfill(jsonRoute(created))
}
// GET /secrets (list).
return respondList(route)
})
return backend
}
/**
* Open the settings dialog and land on the Secrets panel, waiting for both the
* provider allowlist and the secret list to resolve so subsequent assertions
* are not racing the panel's on-mount fetches. Returns the dialog root locator
* for scoping the caller's assertions.
*/
export async function openSecretsPanel(settingDialog: SettingDialog) {
const { page } = settingDialog
await settingDialog.open()
const providersResolved = page.waitForResponse((r) =>
r.url().includes('/api/secrets/providers')
)
const listResolved = page.waitForResponse(
(r) =>
/\/api\/secrets(\?|$)/.test(r.url()) && r.request().method() === 'GET'
)
await settingDialog.category('Secrets').click()
await Promise.all([providersResolved, listResolved])
return settingDialog.root
}

View File

@@ -1,13 +1,11 @@
import { expect } from '@playwright/test'
import type { Page, Route } from '@playwright/test'
import { ComfyPage, comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
import {
SECRETS_BOOT_FEATURES,
SECRETS_BOOT_SETTINGS,
mockSecretsBackend,
openSecretsPanel
} from '@e2e/fixtures/utils/cloudSecretsMocks'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
/**
* End-to-end coverage for the user-secrets (API keys) surface in the cloud app:
@@ -17,28 +15,164 @@ import {
*
* Drives a raw `page` against fully-mocked endpoints (the `comfyPage` fixture
* would reach the OSS devtools backend during setup); `mockCloudBoot` +
* `bootCloud` boot the app signed-in, and `mockSecretsBackend` layers a
* stateful in-memory `/secrets` backend on top so the flow is deterministic
* and never touches a real server. A bare `ComfyPage` (constructed, never
* `setup()`) supplies the shared `SettingDialog` page object without the
* backend-touching fixture setup.
* `bootCloud` boot the app signed-in, and this spec layers a stateful in-memory
* `/secrets` backend on top so the flow is deterministic and never touches a
* real server.
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
// `/api/features` is the remote-config source. Enabling user secrets is what
// surfaces the Secrets settings panel for a signed-in user.
const BOOT_FEATURES = {
user_secrets_enabled: true
} satisfies RemoteConfig
// TutorialCompleted suppresses the new-user template browser, whose modal
// overlay (z-1700) would otherwise intercept clicks on the settings dialog.
const BOOT_SETTINGS = { 'Comfy.TutorialCompleted': true }
// The plaintext key a user types in. It must be sent on create but NEVER echoed
// back by the API or rendered anywhere in the UI.
const RUNWAY_KEY_VALUE = 'sk-runway-do-not-echo-0xDEADBEEF'
interface SecretRecord {
id: string
name: string
provider?: string
created_at: string
updated_at: string
last_used_at?: string
}
interface CreateCapture {
name?: string
provider?: string
secret_value?: string
}
interface SecretsBackend {
/** Bodies received by POST /secrets, in order — for asserting what was sent. */
createRequests: CreateCapture[]
/** Current server-side store — for asserting delete actually removed a row. */
store: SecretRecord[]
}
/**
* Stateful mock of the ingest `/secrets` surface. A single route handler
* branches on path + method so registration order can never make a specific
* path (`/secrets/providers`, `/secrets/:id`) lose to the collection glob.
*
* `providerIds` models entitlement: an entitled account sees runway/gemini,
* a non-entitled account gets an empty list (the server omits them).
*/
async function mockSecretsBackend(
page: Page,
providerIds: string[]
): Promise<SecretsBackend> {
const backend: SecretsBackend = { createRequests: [], store: [] }
let idSeq = 0
const respondList = (route: Route) =>
route.fulfill(jsonRoute({ data: backend.store }))
await page.route('**/api/secrets**', async (route) => {
const request = route.request()
const { pathname } = new URL(request.url())
const method = request.method()
// The glob `**/api/secrets**` also matches the panel's own lazy-loaded
// source module (`/src/platform/secrets/api/secretsApi.ts`), whose path
// contains the `/api/secrets` substring. Fulfilling that dev-server module
// request with JSON breaks the dynamic import and the panel never mounts.
// Anchor to the start of the pathname so only genuine `/api/secrets…` API
// routes are handled; everything else falls through to the real Vite server.
if (!/^\/api\/secrets(\/|$)/.test(pathname)) {
return route.continue()
}
// GET /secrets/providers — the entitlement-gated provider allowlist.
if (pathname.endsWith('/secrets/providers')) {
return route.fulfill(
jsonRoute({ data: providerIds.map((id) => ({ id })) })
)
}
// /secrets/:id — item routes (only DELETE is exercised by this flow).
const itemMatch = pathname.match(/\/secrets\/([^/]+)$/)
if (itemMatch) {
const id = itemMatch[1]
if (method === 'DELETE') {
backend.store = backend.store.filter((s) => s.id !== id)
return route.fulfill({ status: 204, body: '' })
}
return respondList(route)
}
// /secrets — collection routes.
if (method === 'POST') {
const body = (request.postDataJSON() ?? {}) as CreateCapture
backend.createRequests.push(body)
idSeq += 1
const created: SecretRecord = {
id: `00000000-0000-4000-8000-${String(idSeq).padStart(12, '0')}`,
name: body.name ?? '',
provider: body.provider,
created_at: '2026-07-08T00:00:00Z',
updated_at: '2026-07-08T00:00:00Z'
}
backend.store.push(created)
// Response echoes metadata ONLY — the schema has no secret_value field.
return route.fulfill(jsonRoute(created))
}
// GET /secrets (list).
return respondList(route)
})
return backend
}
/**
* Open the settings dialog and land on the Secrets panel, waiting for both the
* provider allowlist and the secret list to resolve so subsequent assertions
* are not racing the panel's on-mount fetches.
*/
async function openSecretsPanel(page: Page) {
const settingsDialog = page.getByTestId('settings-dialog')
await page.evaluate(() => {
const app = window.app
if (!app) throw new Error('window.app is not available')
return app.extensionManager.command.execute('Comfy.ShowSettingsDialog')
})
await settingsDialog.waitFor({ state: 'visible' })
const providersResolved = page.waitForResponse((r) =>
r.url().includes('/api/secrets/providers')
)
const listResolved = page.waitForResponse(
(r) =>
/\/api\/secrets(\?|$)/.test(r.url()) && r.request().method() === 'GET'
)
await settingsDialog
.locator('nav')
.getByRole('button', { name: 'Secrets' })
.click()
await Promise.all([providersResolved, listResolved])
return settingsDialog
}
test.describe('Cloud user secrets (API keys)', { tag: '@cloud' }, () => {
test('an entitled account can add, list, and delete a provider key', async ({
page,
request
page
}) => {
test.slow()
await mockCloudBoot(page, {
features: SECRETS_BOOT_FEATURES,
settings: SECRETS_BOOT_SETTINGS
features: BOOT_FEATURES,
settings: BOOT_SETTINGS
})
await bootCloud(page)
const backend = await mockSecretsBackend(page, ['runway', 'gemini'])
@@ -48,8 +182,7 @@ test.describe('Cloud user secrets (API keys)', { tag: '@cloud' }, () => {
timeout: 45_000
})
const comfyPage = new ComfyPage(page, request)
const settingsDialog = await openSecretsPanel(comfyPage.settingDialog)
const settingsDialog = await openSecretsPanel(page)
// Empty state before anything is added.
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
@@ -86,22 +219,6 @@ test.describe('Cloud user secrets (API keys)', { tag: '@cloud' }, () => {
// ...but the value must never be echoed back into the list — the API
// response carries metadata only, so nothing should render it as text.
await expect(page.getByText(RUNWAY_KEY_VALUE)).toHaveCount(0)
// `getByText` only sees text nodes; a value reflected into an `<input>` or
// masked field would slip past it, so assert no field carries it either.
// The list has already settled above, so this is a single immediate
// assertion — a poll-until-false could mask a value that briefly echoed
// into a field and then cleared within the polling window.
const secretEchoedInField = await page
.locator('input, textarea')
.evaluateAll(
(fields, value) =>
fields.some(
(field) =>
(field as HTMLInputElement | HTMLTextAreaElement).value === value
),
RUNWAY_KEY_VALUE
)
expect(secretEchoedInField).toBe(false)
// --- DELETE ----------------------------------------------------------
await settingsDialog
@@ -121,14 +238,13 @@ test.describe('Cloud user secrets (API keys)', { tag: '@cloud' }, () => {
})
test('a non-entitled account never sees the gated providers', async ({
page,
request
page
}) => {
test.slow()
await mockCloudBoot(page, {
features: SECRETS_BOOT_FEATURES,
settings: SECRETS_BOOT_SETTINGS
features: BOOT_FEATURES,
settings: BOOT_SETTINGS
})
await bootCloud(page)
// Non-entitled: the server omits runway/gemini from the allowlist.
@@ -139,8 +255,7 @@ test.describe('Cloud user secrets (API keys)', { tag: '@cloud' }, () => {
timeout: 45_000
})
const comfyPage = new ComfyPage(page, request)
const settingsDialog = await openSecretsPanel(comfyPage.settingDialog)
const settingsDialog = await openSecretsPanel(page)
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
// The add form opens, but its provider dropdown is empty — the gated

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.48.1",
"version": "1.48.0",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { ZIndex } from '@primeuix/utils/zindex'
import type { MenuItem } from 'primevue/menuitem'
import {
DropdownMenuArrow,
@@ -11,15 +12,21 @@ import { computed, ref, toValue } from 'vue'
import DropdownItem from '@/components/common/DropdownItem.vue'
import Button from '@/components/ui/button/Button.vue'
import { useModalLiftedZIndex } from '@/composables/useModalLiftedZIndex'
import { cn } from '@comfyorg/tailwind-utils'
import type { ButtonVariants } from '../ui/button/button.variants'
// Shared base for @primeuix's auto-incrementing 'modal' z-index counter.
const MODAL_BASE_Z_INDEX = 1700
defineOptions({
inheritAttrs: false
})
const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
const {
itemClass: itemProp,
contentClass: contentProp,
modal = true
} = defineProps<{
entries?: MenuItem[]
icon?: string
to?: string | HTMLElement
@@ -27,6 +34,7 @@ const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
contentClass?: string
buttonSize?: ButtonVariants['size']
buttonClass?: string
modal?: boolean
}>()
const itemClass = computed(() =>
@@ -43,12 +51,19 @@ const contentClass = computed(() =>
)
)
// Body-portaled content keeps its static z-1700 unless a dialog that joined
// @primeuix's auto-incrementing 'modal' counter is open above it; then lift
// past that dialog so the menu isn't hidden behind it.
const open = ref(false)
const contentStyle = useModalLiftedZIndex(open)
const contentStyle = computed(() => {
if (!open.value) return undefined
const topZIndex = ZIndex.getCurrent('modal')
return topZIndex >= MODAL_BASE_Z_INDEX ? { zIndex: topZIndex + 1 } : undefined
})
</script>
<template>
<DropdownMenuRoot v-model:open="open">
<DropdownMenuRoot 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

@@ -32,9 +32,6 @@ const Body = defineComponent({
setup: () => () => h('p', { 'data-testid': 'body' }, 'body content')
})
const flushPromises = () =>
new Promise<void>((resolve) => setTimeout(resolve, 0))
const ClosedNonModalDialog = defineComponent({
name: 'ClosedNonModalDialog',
setup: () => () =>
@@ -364,82 +361,6 @@ describe('GlobalDialog Reka overlay scrim', () => {
})
})
describe('GlobalDialog Reka focus-outside binding', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
afterEach(() => {
cleanup()
})
// Reka's DismissableLayer fires focus-outside off a real focus transition
// (blur inside the layer, then focusin on the new target), so drive the
// mounted binding by moving focus to a fresh element outside the dialog
// rather than dispatching a synthetic event.
async function moveFocusToPlainElementOutside() {
const outside = document.createElement('button')
document.body.appendChild(outside)
outside.focus()
return () => outside.remove()
}
it('dismisses on focus-outside by default', async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'focus-default',
title: 'Focus dismisses',
component: Body,
dialogComponentProps: { renderer: 'reka', modal: false }
})
await screen.findByRole('dialog')
const removeOutside = await moveFocusToPlainElementOutside()
try {
await waitFor(() =>
expect(store.isDialogOpen('focus-default')).toBe(false)
)
} finally {
removeOutside()
}
})
it('does not dismiss on focus-outside when dismissOnFocusOutside is false', async () => {
// Exercises GlobalDialog's own template wiring
// `@focus-outside="(e) => onRekaFocusOutside(e, item.dialogComponentProps)"`
// through a mounted dialog — the direct `onRekaFocusOutside` unit test can't
// catch a regression that drops the props argument here. The positive
// control above proves the focus-outside path really fires, so this staying
// open isolates the opt-out flag rather than a dead event.
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'focus-opted-out',
title: 'Focus blocked',
component: Body,
dialogComponentProps: {
renderer: 'reka',
modal: false,
dismissOnFocusOutside: false
}
})
await screen.findByRole('dialog')
const removeOutside = await moveFocusToPlainElementOutside()
try {
// Drain every pending microtask so a wrongful dismiss lands before we
// assert, regardless of how many awaits deep the handler chain runs.
await flushPromises()
expect(store.isDialogOpen('focus-opted-out')).toBe(true)
} finally {
removeOutside()
}
})
})
describe('shouldPreventRekaDismiss', () => {
function makeEvent(target: Element | null) {
let prevented = false

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

@@ -12,7 +12,7 @@ const PRIMEVUE_OVERLAY_SELECTORS =
// dismiss itself. These selectors cover the portaled roots 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,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

@@ -515,52 +515,57 @@
},
"survey": {
"errors": {
"answerTooLong": "يرجى إبقاء إجابتك أقل من {max} حرفًا.",
"chooseAnOption": "يرجى اختيار خيار.",
"describeAnswer": "يرجى وصف إجابتك.",
"selectAtLeastOne": "يرجى اختيار خيار واحد على الأقل."
},
"intro": "ساعدنا في تخصيص تجربتك مع ComfyUI.",
"options": {
"experience": {
"new": "جديد على ComfyUI",
"pro": "أنا مستخدم محترف",
"some": "لدي معرفة جيدة"
},
"focus": {
"custom_nodes": "عُقد مخصصة",
"pipelines": "مسارات مؤتمتة",
"products": "منتجات للآخرين"
"familiarity": {
"advanced": "مستخدم متقدم (سير عمل مخصصة)",
"basics": "مرتاح مع الأساسيات",
"expert": "خبير (أساعد الآخرين)",
"new": "جديد في ComfyUI (لم أستخدمه من قبل)",
"starting": "في البداية فقط (أتابع الدروس التعليمية)"
},
"intent": {
"apps_api": "تطبيقات وواجهات برمجة التطبيقات",
"exploring": "أستكشف فقط",
"3d_game": "أصول ثلاثية الأبعاد / أصول ألعاب",
"api": "نقاط نهاية API لتشغيل مسارات العمل",
"apps": "تطبيقات مبسطة من مسارات العمل",
"audio": "صوت / موسيقى",
"custom_nodes": "عُقد مخصصة",
"images": "صور",
"other": "شيء آخر",
"otherPlaceholder": "ماذا تريد أن تصنع؟",
"video": "فيديو",
"not_sure": "لست متأكداً",
"videos": "فيديوهات",
"workflows": "مسارات عمل أو خطوط معالجة مخصصة"
},
"source": {
"community": جتمع أو منتدى",
"conference": ؤتمر أو فعالية",
"discord": "ديسكورد / مجتمع",
"friend": "صديق أو زميل",
"other": "أخرى",
"otherPlaceholder": "من أين وجدتنا؟",
"search": "جوجل / بحث",
"social": "وسائل التواصل الاجتماعي"
},
"source_social": {
"discord": "ديسكورد",
"github": "GitHub",
"instagram": "إنستغرام",
"linkedin": "لينكدإن",
"newsletter": "النشرة البريدية أو مدونة",
"other": "أخرى",
"reddit": "ريديت",
"tiktok": "تيك توك",
"twitter": "X (تويتر)",
"search": "جوجل / بحث",
"twitter": "تويتر / X",
"youtube": "يوتيوب"
},
"usage": {
"education": "تعليمي (طالب أو معلم)",
"personal": "استخدام شخصي",
"work": "عمل"
}
},
"otherPlaceholder": "أخبرنا المزيد",
"placeholder": "نص بديل لأسئلة الاستبيان",
"steps": {
"familiarity": "ما مدى معرفتك بـ ComfyUI؟",
"intent": "ما الذي ترغب في إنشائه باستخدام ComfyUI؟",
"source": "من أين سمعت عن ComfyUI؟",
"usage": "كيف تخطط لاستخدام ComfyUI؟"
},
"title": "استبيان السحابة"
}
},
@@ -573,11 +578,10 @@
"cloudStart_learnAboutButton": "تعرف على السحابة",
"cloudStart_title": "ابدأ الإبداع في ثوانٍ",
"cloudStart_wantToRun": "هل تريد تشغيل ComfyUI محليًا بدلاً من ذلك؟",
"cloudSurvey_steps_experience": "ما مدى معرفتك بـ ComfyUI؟",
"cloudSurvey_steps_focus": "ماذا تبني؟",
"cloudSurvey_steps_familiarity": "ما مدى معرفتك بـ ComfyUI؟",
"cloudSurvey_steps_intent": "ما الذي ترغب في إنشائه باستخدام ComfyUI؟",
"cloudSurvey_steps_source": "من أين سمعت عن ComfyUI؟",
"cloudSurvey_steps_source_social": "أي منصة؟",
"cloudSurvey_steps_usage": "كيف تخطط لاستخدام ComfyUI؟",
"cloudWaitlist_contactLink": "هنا",
"cloudWaitlist_questionsText": "أسئلة؟ اتصل بنا",
"color": {

View File

@@ -2414,16 +2414,6 @@
"tooltipLearnMore": "Learn more..."
}
},
"desktopLogin": {
"confirmSummary": "Approve desktop sign-in?",
"confirmMessage": "The ComfyUI desktop app is waiting to sign in with your account. Only continue if you just started signing in from the ComfyUI desktop app.",
"successSummary": "Signed in",
"successDetail": "You can return to the ComfyUI desktop app.",
"expiredSummary": "Desktop sign-in failed",
"expiredDetail": "The sign-in request expired or was already used. Start signing in again from the ComfyUI desktop app.",
"failedSummary": "Desktop sign-in failed",
"failedDetail": "Something went wrong completing the desktop sign-in. Start signing in again from the ComfyUI desktop app."
},
"validation": {
"invalidEmail": "Invalid email address",
"required": "Required",
@@ -2603,7 +2593,7 @@
"additionalCreditsInfo": "About additional credits",
"additionalCredits": "Additional credits",
"additionalCreditsInUse": "In use",
"usedAfterMonthly": "Used after monthly runs out",
"usedAfterMonthly": "Used after plan credits run out",
"monthlyCreditsUsedUpTitle": "Monthly credits are used up. Refills {date}",
"monthlyCreditsUsedUpTitleNoDate": "Monthly credits are used up",
"monthlyCreditsUsedUpDescription": "You're now spending additional credits.",
@@ -2841,7 +2831,10 @@
"planUpdated": "Your plan has been successfully updated.",
"receiptEmailed": "A receipt has been emailed to you.",
"sendInvites": "Send invites"
}
},
"enterprisePlanName": "Enterprise",
"percentUsed": "{percent}% used",
"usageProgress": "{used} of {total} credits used"
},
"userSettings": {
"title": "My Account Settings",
@@ -2856,7 +2849,7 @@
"workspacePanel": {
"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",
@@ -2871,12 +2864,17 @@
"pendingInvitesCount": "{count} pending invite | {count} pending invites",
"tabs": {
"active": "Active",
"pendingCount": "Pending ({count})"
"pendingCount": "Pending ({count})",
"membersCount": "Members ({count})",
"pending": "Pending"
},
"columns": {
"inviteDate": "Invite date",
"expiryDate": "Expiry date",
"role": "Role"
"role": "Role",
"creditsUsed": "Credits used this month",
"email": "Email",
"lastActivity": "Last activity"
},
"actions": {
"resendInvite": "Resend invite",
@@ -2892,14 +2890,22 @@
"contactUs": "Contact us",
"noInvites": "No pending invites",
"noMembers": "No members",
"searchPlaceholder": "Search..."
"searchPlaceholder": "Search...",
"activity": {
"daysAgo": "{count} day ago | {count} days ago",
"hoursAgo": "{n} hr ago",
"justNow": "just now",
"minutesAgo": "{n} min ago"
},
"membersUsage": "{count} of {max} total members."
},
"menu": {
"editWorkspace": "Edit workspace details",
"leaveWorkspace": "Leave Workspace",
"deleteWorkspace": "Delete Workspace",
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first",
"creatorCannotLeave": "The workspace creator can't leave the workspace they created"
"creatorCannotLeave": "The workspace creator can't leave the workspace they created",
"renameWorkspace": "Rename Workspace"
},
"editWorkspaceDialog": {
"title": "Edit workspace details",
@@ -2988,6 +2994,136 @@
"failedToDeleteWorkspace": "Failed to delete workspace",
"failedToLeaveWorkspace": "Failed to leave workspace",
"failedToFetchWorkspaces": "Failed to load workspaces"
},
"charactersLeft": "{count} character left | {count} characters left",
"doubleClickToRename": "Double-click to rename",
"editWorkspaceImage": "Edit workspace image",
"memberLimitDialog": {
"message": "All seats are filled. To invite someone new, remove a member, rescind an invite, or request more seats.",
"title": "Workspace is at the member limit"
},
"requestMore": "Request more",
"workflowQueuedDialog": {
"message": "Max workflow capacity reached. It'll start automatically as capacity opens up. If this happens often, you can also request for more capacity.",
"title": "Your workflow is queued"
},
"billingStatus": {
"ending": {
"body": "Members keep full access until then. Reactivate to keep your shared credits and seats.",
"reactivate": "Reactivate plan",
"title": "Your team plan ends on {date}"
},
"outOfCredits": {
"addCredits": "Add 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.",
"dismiss": "Dismiss",
"title": "Out of credits"
},
"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.",
"title": "Subscription paused"
},
"updatePayment": "Update payment",
"warning": {
"body": "Your last payment didn't go through. Your subscription will pause on {date} unless payment is updated.",
"title": "Payment declined"
}
},
"overview": {
"changePlan": "Change plan",
"inactive": {
"reactivate": "Reactivate plan",
"subtitle": "Reactivate your team plan to add more members and run workflows",
"subtitleEnterprise": "Reactivate your enterprise plan to add more members and run workflows",
"title": "Inactive team subscription",
"titleEnterprise": "Inactive enterprise subscription"
},
"learnMore": "Learn more",
"managePayment": "Manage payment",
"messageSupport": "Message support",
"paused": "Paused",
"perMonth": "mo",
"pricingTable": "Partner Nodes pricing table",
"renewsOn": "Renews on {date}",
"seeMore": "See more",
"snapshot": {
"creditsUsed": "Credits used",
"empty": {
"recentActivity": "No activity yet",
"topSpenders": "No credits used yet this month"
},
"lastActivity": "Last activity",
"recentActivity": "Recent activity",
"topSpenders": "Top spenders",
"user": "User"
},
"nextInvoice": "Next month invoice",
"usd": "USD"
},
"invoices": {
"fullHistory": "Full invoice history"
},
"planCredits": {
"tabs": {
"invoices": "Invoices",
"overview": "Credits",
"activity": "Activity"
}
},
"autoReload": {
"badge": {
"off": "Off",
"paused": "Paused"
},
"dialog": {
"allowsReloads": "Allows {count} reload /mo | Allows {count} reloads /mo",
"amountLabel": "Add this amount of credits:",
"budgetPlaceholderCredits": "Enter an amount of credits",
"budgetPlaceholderUsd": "Enter an amount of dollars",
"budgetToggleHint": "Limit how much is auto-reloaded per month",
"budgetToggleLabel": "Monthly budget",
"cancel": "Cancel",
"minReload": "Minimum amount is {amount}",
"thresholdLabel": "When credits drop below:",
"title": "Auto-reload credits",
"update": "Update"
},
"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"
},
"enabled": "Enabled",
"subtitle": "Automatically add credits when your balance runs low, within an optional monthly budget.",
"tile": {
"label": "Auto-reload",
"monthlyBudget": "Monthly budget",
"percentSpent": "{percent}% spent",
"spentOfBudget": "{spent} of {budget}",
"whenBelow": "when credits drop below"
},
"title": "Credit auto-reload"
},
"activity": {
"columns": {
"creditsUsed": "Credits",
"date": "Date",
"eventDetails": "Event details",
"eventType": "Event type",
"user": "User"
},
"fullActivity": "Full activity",
"hoverCard": {
"lastActivity": "Last activity",
"partnerNodeUsed": "Partner node used",
"totalCreditsUsed": "Total credits used"
},
"perUserHint": "Looking for total usage per user?",
"seeMembers": "See Members",
"empty": "No activity yet."
}
},
"teamWorkspacesDialog": {
@@ -3000,7 +3136,7 @@
"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",
@@ -3010,7 +3146,8 @@
"roleMember": "Member",
"createWorkspace": "Create a workspace",
"maxWorkspacesReached": "You can only own 10 workspaces. Delete one to create a new one.",
"failedToSwitch": "Failed to switch workspace"
"failedToSwitch": "Failed to switch workspace",
"roleAdmin": "Admin"
},
"selectionToolbox": {
"executeButton": {

View File

@@ -515,52 +515,57 @@
},
"survey": {
"errors": {
"answerTooLong": "Por favor, mantén tu respuesta por debajo de {max} caracteres.",
"chooseAnOption": "Por favor, elige una opción.",
"describeAnswer": "Por favor, describe tu respuesta.",
"selectAtLeastOne": "Por favor, selecciona al menos una opción."
},
"intro": "Ayúdanos a personalizar tu experiencia con ComfyUI.",
"options": {
"experience": {
"new": "Nuevo en ComfyUI",
"pro": "Soy usuario avanzado",
"some": "Ya tengo experiencia"
},
"focus": {
"custom_nodes": "Nodos personalizados",
"pipelines": "Pipelines automatizados",
"products": "Productos para otros"
"familiarity": {
"advanced": "Usuario avanzado (flujos de trabajo personalizados)",
"basics": "Cómodo con lo básico",
"expert": "Experto (ayudo a otros)",
"new": "Nuevo en ComfyUI (nunca lo he usado antes)",
"starting": "Recién comenzando (siguiendo tutoriales)"
},
"intent": {
"apps_api": "Aplicaciones y APIs",
"exploring": "Solo explorando",
"3d_game": "Recursos 3D / recursos para juegos",
"api": "Endpoints de API para ejecutar flujos de trabajo",
"apps": "Apps simplificadas a partir de flujos de trabajo",
"audio": "Audio / música",
"custom_nodes": "Nodos personalizados",
"images": "Imágenes",
"other": "Otra cosa",
"otherPlaceholder": "¿Qué quieres crear?",
"video": "Video",
"not_sure": "No estoy seguro",
"videos": "Videos",
"workflows": "Flujos de trabajo o pipelines personalizados"
},
"source": {
"community": "Una comunidad o foro",
"conference": "Conferencia o evento",
"discord": "Discord / comunidad",
"friend": "Amigo o colega",
"other": "Otro",
"otherPlaceholder": "¿Dónde nos encontraste?",
"search": "Google / búsqueda",
"social": "Redes sociales"
},
"source_social": {
"discord": "Discord",
"github": "GitHub",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "Newsletter o blog",
"other": "Otro",
"reddit": "Reddit",
"tiktok": "TikTok",
"twitter": "X (Twitter)",
"search": "Google / búsqueda",
"twitter": "Twitter / X",
"youtube": "YouTube"
},
"usage": {
"education": "Educación (estudiante o docente)",
"personal": "Uso personal",
"work": "Trabajo"
}
},
"otherPlaceholder": "Cuéntanos más",
"placeholder": "Marcador de posición para preguntas de la encuesta",
"steps": {
"familiarity": "¿Qué tan familiarizado estás con ComfyUI?",
"intent": "¿Qué quieres crear con ComfyUI?",
"source": "¿Dónde escuchaste sobre ComfyUI?",
"usage": "¿Cómo planeas usar ComfyUI?"
},
"title": "Encuesta en la Nube"
}
},
@@ -573,11 +578,10 @@
"cloudStart_learnAboutButton": "Conoce más sobre Cloud",
"cloudStart_title": "comienza a crear en segundos",
"cloudStart_wantToRun": "¿Prefieres ejecutar ComfyUI localmente?",
"cloudSurvey_steps_experience": "¿Qué tanto conoces ComfyUI?",
"cloudSurvey_steps_focus": "¿Qué estás construyendo?",
"cloudSurvey_steps_familiarity": "¿Qué tan familiarizado estás con ComfyUI?",
"cloudSurvey_steps_intent": "¿Qué quieres crear con ComfyUI?",
"cloudSurvey_steps_source": "¿Dónde escuchaste sobre ComfyUI?",
"cloudSurvey_steps_source_social": "¿En qué plataforma?",
"cloudSurvey_steps_usage": "¿Cómo planeas usar ComfyUI?",
"cloudWaitlist_contactLink": "aquí",
"cloudWaitlist_questionsText": "¿Preguntas? Contáctanos",
"color": {

View File

@@ -515,52 +515,57 @@
},
"survey": {
"errors": {
"answerTooLong": "لطفاً پاسخ خود را کمتر از {max} نویسه نگه دارید.",
"chooseAnOption": "لطفاً یک گزینه را انتخاب کنید.",
"describeAnswer": "لطفاً پاسخ خود را توضیح دهید.",
"selectAtLeastOne": "لطفاً حداقل یک گزینه را انتخاب کنید."
},
"intro": "به ما کمک کنید تا تجربه شما از ComfyUI را متناسب‌سازی کنیم.",
"options": {
"experience": {
"new": "جدید در ComfyUI",
"pro": "کاربر حرفه‌ای هستم",
"some": "آشنایی نسبی دارم"
},
"focus": {
"custom_nodes": "Nodeهای سفارشی",
"pipelines": "پایپ‌لاین‌های خودکار",
"products": "محصولات برای دیگران"
"familiarity": {
"advanced": "کاربر پیشرفته (جریان‌کارهای سفارشی)",
"basics": "آشنایی با مبانی",
"expert": "کاربر خبره (به دیگران کمک می‌کنم)",
"new": "جدید در ComfyUI (تا کنون استفاده نکرده‌ام)",
"starting": "تازه شروع کرده‌ام (در حال دنبال کردن آموزش‌ها)"
},
"intent": {
"apps_api": "اپلیکیشن‌ها و APIها",
"exploring": "فقط در حال بررسی",
"3d_game": "دارایی سه‌بعدی / دارایی بازی",
"api": "API endpoint برای اجرای workflow",
"apps": "اپلیکیشن ساده‌شده از workflow",
"audio": "صدا / موسیقی",
"custom_nodes": "node سفارشی",
"images": "تصویر",
"other": "چیز دیگری",
"otherPlaceholder": "چه چیزی می‌خواهید بسازید؟",
"video": "ویدیو",
"not_sure": "مطمئن نیستم",
"videos": "ویدیو",
"workflows": "workflow یا pipeline سفارشی"
},
"source": {
"community": "انجمن یا فروم",
"conference": "کنفرانس یا رویداد",
"discord": "Discord / انجمن",
"friend": "دوست یا همکار",
"other": "سایر",
"otherPlaceholder": "از کجا با ما آشنا شدید؟",
"search": "Google / جستجو",
"social": "رسانه‌های اجتماعی"
},
"source_social": {
"discord": "Discord",
"github": "GitHub",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "خبرنامه یا وبلاگ",
"other": "سایر",
"reddit": "Reddit",
"tiktok": "TikTok",
"twitter": "X (Twitter)",
"search": "Google / جستجو",
"twitter": "Twitter / X",
"youtube": "YouTube"
},
"usage": {
"education": "آموزشی (دانشجو یا مدرس)",
"personal": "استفاده شخصی",
"work": "کاری"
}
},
"otherPlaceholder": "بیشتر توضیح دهید",
"placeholder": "جای‌نگهدار سوالات نظرسنجی",
"steps": {
"familiarity": "تا چه حد با ComfyUI آشنایی دارید؟",
"intent": "مایل هستید با ComfyUI چه چیزی ایجاد کنید؟",
"source": "از کجا با ComfyUI آشنا شدید؟",
"usage": "برنامه شما برای استفاده از ComfyUI چیست؟"
},
"title": "نظرسنجی ابری"
}
},
@@ -573,11 +578,10 @@
"cloudStart_learnAboutButton": "درباره Cloud بیشتر بدانید",
"cloudStart_title": "در چند ثانیه شروع به خلق کنید",
"cloudStart_wantToRun": "مایلید ComfyUI را به صورت محلی اجرا کنید؟",
"cloudSurvey_steps_experience": "تا چه حد با ComfyUI آشنایی دارید؟",
"cloudSurvey_steps_focus": "در حال ساخت چه چیزی هستید؟",
"cloudSurvey_steps_familiarity": "تا چه اندازه با ComfyUI آشنایی دارید؟",
"cloudSurvey_steps_intent": "مایل هستید با ComfyUI چه چیزی ایجاد کنید؟",
"cloudSurvey_steps_source": "از کجا با ComfyUI آشنا شدید؟",
"cloudSurvey_steps_source_social": "کدام پلتفرم؟",
"cloudSurvey_steps_usage": "برنامه شما برای استفاده از ComfyUI چیست؟",
"cloudWaitlist_contactLink": "اینجا",
"cloudWaitlist_questionsText": "سؤالی دارید؟ با ما تماس بگیرید",
"color": {

View File

@@ -515,52 +515,57 @@
},
"survey": {
"errors": {
"answerTooLong": "Veuillez limiter votre réponse à {max} caractères.",
"chooseAnOption": "Veuillez choisir une option.",
"describeAnswer": "Veuillez décrire votre réponse.",
"selectAtLeastOne": "Veuillez sélectionner au moins une option."
},
"intro": "Aidez-nous à personnaliser votre expérience ComfyUI.",
"options": {
"experience": {
"new": "Nouveau sur ComfyUI",
"pro": "Utilisateur avancé",
"some": "Je me débrouille"
},
"focus": {
"custom_nodes": "Nœuds personnalisés",
"pipelines": "Pipelines automatisés",
"products": "Produits pour les autres"
"familiarity": {
"advanced": "Utilisateur avancé (workflows personnalisés)",
"basics": "À l'aise avec les bases",
"expert": "Expert (j'aide les autres)",
"new": "Nouveau sur ComfyUI (jamais utilisé auparavant)",
"starting": "Je débute (je suis des tutoriels)"
},
"intent": {
"apps_api": "Applications et API",
"exploring": "Je découvre simplement",
"3d_game": "Assets 3D / assets de jeu",
"api": "Points de terminaison API pour exécuter des workflows",
"apps": "Applications simplifiées à partir de workflows",
"audio": "Audio / musique",
"custom_nodes": "Nœuds personnalisés",
"images": "Images",
"other": "Autre chose",
"otherPlaceholder": "Qu'aimeriez-vous créer ?",
"video": "Vidéo",
"not_sure": "Pas sûr",
"videos": "Vidéos",
"workflows": "Workflows ou pipelines personnalisés"
},
"source": {
"community": "Une communauté ou un forum",
"conference": "Conférence ou événement",
"discord": "Discord / communauté",
"friend": "Ami ou collègue",
"other": "Autre",
"otherPlaceholder": "Où nous avez-vous trouvés ?",
"search": "Google / recherche",
"social": "Réseaux sociaux"
},
"source_social": {
"discord": "Discord",
"github": "GitHub",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "Newsletter ou blog",
"other": "Autre",
"reddit": "Reddit",
"tiktok": "TikTok",
"twitter": "X (Twitter)",
"search": "Google / recherche",
"twitter": "Twitter / X",
"youtube": "YouTube"
},
"usage": {
"education": "Éducation (étudiant ou enseignant)",
"personal": "Usage personnel",
"work": "Travail"
}
},
"otherPlaceholder": "Dites-nous en plus",
"placeholder": "Texte indicatif des questions de l'enquête",
"steps": {
"familiarity": "Quelle est votre familiarité avec ComfyUI ?",
"intent": "Que souhaitez-vous créer avec ComfyUI ?",
"source": "Où avez-vous entendu parler de ComfyUI ?",
"usage": "Comment prévoyez-vous d'utiliser ComfyUI ?"
},
"title": "Enquête Cloud"
}
},
@@ -573,11 +578,10 @@
"cloudStart_learnAboutButton": "En savoir plus sur Cloud",
"cloudStart_title": "créez en quelques secondes",
"cloudStart_wantToRun": "Vous préférez exécuter ComfyUI localement ?",
"cloudSurvey_steps_experience": "Quel est votre niveau de connaissance de ComfyUI ?",
"cloudSurvey_steps_focus": "Qu'êtes-vous en train de créer ?",
"cloudSurvey_steps_familiarity": "Quelle est votre familiarité avec ComfyUI ?",
"cloudSurvey_steps_intent": "Que souhaitez-vous créer avec ComfyUI ?",
"cloudSurvey_steps_source": "Où avez-vous entendu parler de ComfyUI ?",
"cloudSurvey_steps_source_social": "Quelle plateforme ?",
"cloudSurvey_steps_usage": "Comment prévoyez-vous d'utiliser ComfyUI ?",
"cloudWaitlist_contactLink": "ici",
"cloudWaitlist_questionsText": "Des questions ? Contactez-nous",
"color": {

View File

@@ -515,52 +515,57 @@
},
"survey": {
"errors": {
"answerTooLong": "אנא שמרו את התשובה שלכם עד {max} תווים.",
"chooseAnOption": "אנא בחר אפשרות.",
"describeAnswer": "אנא תאר את תשובתך.",
"selectAtLeastOne": "אנא בחר לפחות אפשרות אחת."
},
"intro": "עזרו לנו להתאים את חוויית ה-ComfyUI שלך.",
"options": {
"experience": {
"new": "חדש/ה ב-ComfyUI",
"pro": "משתמש/ת מתקדם/ת",
"some": כיר/ה את המערכת"
},
"focus": {
"custom_nodes": "צמתים מותאמים אישית",
"pipelines": "צינורות עבודה אוטומטיים",
"products": "מוצרים לאחרים"
"familiarity": {
"advanced": "מתקדם — בונה ועורך תהליכי עבודה",
"basics": "בינוני — מרגיש בנוח עם היסודות",
"expert": ומחה — אני עוזר לאחרים",
"new": "חדש — מעולם לא השתמשתי",
"starting": "מתחיל — עוקב אחר מדריכים"
},
"intent": {
"apps_api": "אפליקציות ו-API",
"exploring": "רק בודק/ת",
"3d_game": "נכסי תלת-ממד / נכסי משחקים",
"api": "נקודות קצה של API להרצת תהליכי עבודה",
"apps": "יישומים מפושטים מתהליכי עבודה",
"audio": "שמע / מוזיקה",
"custom_nodes": "צמתים מותאמים",
"images": "תמונות",
"other": "משהו אחר",
"otherPlaceholder": "מה תרצו ליצור?",
"video": "וידאו",
"not_sure": "לא בטוח",
"videos": "סרטונים",
"workflows": "תהליכי עבודה או צינורות (pipelines) מותאמים"
},
"source": {
"community": "קהילה או פורום",
"conference": "כנס או אירוע",
"discord": "Discord / קהילה",
"friend": "חבר או עמית",
"other": "אחר",
"otherPlaceholder": "היכן שמעתם עלינו?",
"search": "Google / חיפוש",
"social": "רשתות חברתיות"
},
"source_social": {
"discord": "Discord",
"github": "GitHub",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "ניוזלטר או בלוג",
"other": "אחר",
"reddit": "Reddit",
"tiktok": "TikTok",
"twitter": "X (Twitter)",
"search": "Google / חיפוש",
"twitter": "Twitter / X",
"youtube": "YouTube"
},
"usage": {
"education": "חינוך (סטודנט או מרצה)",
"personal": "שימוש אישי",
"work": "עבודה"
}
},
"otherPlaceholder": "ספרו לנו עוד",
"placeholder": "מציין מיקום לשאלות הסקר",
"steps": {
"familiarity": "עד כמה אתה מכיר את ComfyUI?",
"intent": "מה ברצונך ליצור עם ComfyUI?",
"source": "היכן שמעת על ComfyUI?",
"usage": "כיצד אתה מתכנן להשתמש ב-ComfyUI?"
},
"title": "סקר ענן"
}
},
@@ -573,11 +578,10 @@
"cloudStart_learnAboutButton": "למד על הענן",
"cloudStart_title": "התחל ליצור תוך שניות",
"cloudStart_wantToRun": "מעדיף להריץ את ComfyUI מקומית?",
"cloudSurvey_steps_experience": "עד כמה אתם מכירים את ComfyUI?",
"cloudSurvey_steps_focus": "מה אתם בונים?",
"cloudSurvey_steps_familiarity": "עד כמה אתה מכיר את ComfyUI?",
"cloudSurvey_steps_intent": "מה ברצונך ליצור עם ComfyUI?",
"cloudSurvey_steps_source": "היכן שמעת על ComfyUI?",
"cloudSurvey_steps_source_social": "באיזו פלטפורמה?",
"cloudSurvey_steps_usage": "כיצד אתה מתכנן להשתמש ב-ComfyUI?",
"cloudWaitlist_contactLink": "כאן",
"cloudWaitlist_questionsText": "שאלות? צור איתנו קשר",
"color": {

View File

@@ -515,52 +515,57 @@
},
"survey": {
"errors": {
"answerTooLong": "回答は{max}文字以内で入力してください。",
"chooseAnOption": "オプションを選択してください。",
"describeAnswer": "回答を記述してください。",
"selectAtLeastOne": "少なくとも1つ選択してください。"
},
"intro": "ComfyUIの体験をより最適化するためにご協力ください。",
"options": {
"experience": {
"new": "ComfyUIは初めて",
"pro": "上級ユーザー",
"some": "ある程度使い方が分かる"
},
"focus": {
"custom_nodes": "カスタムノード",
"pipelines": "自動パイプライン",
"products": "他者向けプロダクト"
"familiarity": {
"advanced": "上級ユーザー(カスタムワークフロー)",
"basics": "基本操作に慣れている",
"expert": "エキスパート(他者を支援)",
"new": "ComfyUI初心者使用経験なし",
"starting": "使い始め(チュートリアルをフォロー中)"
},
"intent": {
"apps_api": "アプリ・API",
"exploring": "探索中",
"3d_game": "3Dアセットゲームアセット",
"api": "ワークフロー実行用APIエンドポイント",
"apps": "ワークフローから簡易アプリ作成",
"audio": "音声/音楽",
"custom_nodes": "カスタムノード",
"images": "画像",
"other": "その他",
"otherPlaceholder": "何を作りたいですか?",
"video": "動画",
"not_sure": "まだ分からない",
"videos": "動画",
"workflows": "カスタムワークフローやパイプライン"
},
"source": {
"community": "コミュニティ・フォーラム",
"conference": "カンファレンスやイベント",
"discord": "Discordコミュニティ",
"friend": "友人または同僚",
"other": "その他",
"otherPlaceholder": "どこで私たちを知りましたか?",
"search": "Google検索",
"social": "ソーシャルメディア"
},
"source_social": {
"discord": "Discord",
"github": "GitHub",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "ニュースレターやブログ",
"other": "その他",
"reddit": "Reddit",
"tiktok": "TikTok",
"twitter": "XTwitter",
"search": "Google検索",
"twitter": "Twitter / X",
"youtube": "YouTube"
},
"usage": {
"education": "教育(学生または教育者)",
"personal": "個人利用",
"work": "仕事"
}
},
"otherPlaceholder": "詳細をお聞かせください",
"placeholder": "アンケート質問のプレースホルダー",
"steps": {
"familiarity": "ComfyUIの使用経験はどの程度ですか",
"intent": "ComfyUIで何を作成したいですか",
"source": "ComfyUIをどこで知りましたか",
"usage": "ComfyUIをどのように利用する予定ですか"
},
"title": "クラウドアンケート"
}
},
@@ -573,11 +578,10 @@
"cloudStart_learnAboutButton": "クラウドについて学ぶ",
"cloudStart_title": "数秒で作成を開始",
"cloudStart_wantToRun": "代わりにローカルでComfyUIを実行したいですか",
"cloudSurvey_steps_experience": "ComfyUIの知識レベルは",
"cloudSurvey_steps_focus": "何を作成していますか?",
"cloudSurvey_steps_familiarity": "ComfyUIにどの程度精通していますか",
"cloudSurvey_steps_intent": "ComfyUIで何を作成したいですか",
"cloudSurvey_steps_source": "ComfyUIをどこで知りましたか",
"cloudSurvey_steps_source_social": "どのプラットフォームですか?",
"cloudSurvey_steps_usage": "ComfyUIをどのように利用する予定ですか?",
"cloudWaitlist_contactLink": "こちら",
"cloudWaitlist_questionsText": "質問がありますか?お問い合わせください",
"color": {

View File

@@ -515,52 +515,57 @@
},
"survey": {
"errors": {
"answerTooLong": "답변은 {max}자 이내로 작성해 주세요.",
"chooseAnOption": "옵션을 선택해 주세요.",
"describeAnswer": "답변을 설명해 주세요.",
"selectAtLeastOne": "최소 한 가지 옵션을 선택해 주세요."
},
"intro": "ComfyUI 경험을 맞춤화할 수 있도록 도와주세요.",
"options": {
"experience": {
"new": "ComfyUI가 처음이에요",
"pro": "전문 사용자입니다",
"some": "기본적인 사용법을 알아요"
},
"focus": {
"custom_nodes": "커스텀 노드",
"pipelines": "자동화 파이프라인",
"products": "타인을 위한 제품"
"familiarity": {
"advanced": "고급 사용자 (커스텀 워크플로우 사용)",
"basics": "기본 기능에 익숙함",
"expert": "전문가 (다른 사용자 도움)",
"new": "ComfyUI 처음 사용 (이전에 사용한 적 없음)",
"starting": "막 시작한 단계 (튜토리얼 따라하는 중)"
},
"intent": {
"apps_api": "앱 및 API",
"exploring": "그냥 둘러보는 중",
"3d_game": "3D 에셋 / 게임 에셋",
"api": "워크플로우 실행용 API 엔드포인트",
"apps": "워크플로우 기반 간소화 앱",
"audio": "오디오 / 음악",
"custom_nodes": "커스텀 노드",
"images": "이미지",
"other": "기타",
"otherPlaceholder": "무엇을 만들고 싶으신가요?",
"video": "비디오",
"not_sure": "잘 모르겠음",
"videos": "비디오",
"workflows": "맞춤형 워크플로우 또는 파이프라인"
},
"source": {
"community": "커뮤니티 또는 포럼",
"conference": "컨퍼런스 또는 이벤트",
"discord": "Discord / 커뮤니티",
"friend": "친구 또는 동료",
"other": "기타",
"otherPlaceholder": "어디서 저희를 알게 되셨나요?",
"search": "Google / 검색",
"social": "소셜 미디어"
},
"source_social": {
"discord": "Discord",
"github": "GitHub",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "뉴스레터 또는 블로그",
"other": "기타",
"reddit": "Reddit",
"tiktok": "TikTok",
"twitter": "X (Twitter)",
"search": "Google / 검색",
"twitter": "Twitter / X",
"youtube": "YouTube"
},
"usage": {
"education": "교육용(학생 또는 교육자)",
"personal": "개인용",
"work": "업무용"
}
},
"otherPlaceholder": "자세히 알려주세요",
"placeholder": "설문 질문 자리표시자",
"steps": {
"familiarity": "ComfyUI에 얼마나 익숙하신가요?",
"intent": "ComfyUI로 무엇을 만들고 싶으신가요?",
"source": "ComfyUI를 어디에서 알게 되셨나요?",
"usage": "ComfyUI를 어떻게 사용하실 계획인가요?"
},
"title": "클라우드 설문"
}
},
@@ -573,11 +578,10 @@
"cloudStart_learnAboutButton": "클라우드 알아보기",
"cloudStart_title": "몇 초 만에 제작 시작",
"cloudStart_wantToRun": "로컬에서 ComfyUI를 실행하고 싶으신가요?",
"cloudSurvey_steps_experience": "ComfyUI 얼마나 잘 알고 계신가요?",
"cloudSurvey_steps_focus": "무엇을 만들고 계신가요?",
"cloudSurvey_steps_familiarity": "ComfyUI 얼마나 익숙하신가요?",
"cloudSurvey_steps_intent": "ComfyUI로 무엇을 만들고 싶으신가요?",
"cloudSurvey_steps_source": "ComfyUI를 어디에서 알게 되셨나요?",
"cloudSurvey_steps_source_social": "어떤 플랫폼에서 알게 되셨나요?",
"cloudSurvey_steps_usage": "ComfyUI를 어떻게 사용하실 계획인가요?",
"cloudWaitlist_contactLink": "여기",
"cloudWaitlist_questionsText": "질문이 있으신가요? 문의하기",
"color": {

View File

@@ -515,52 +515,57 @@
},
"survey": {
"errors": {
"answerTooLong": "Por favor, mantenha sua resposta com menos de {max} caracteres.",
"chooseAnOption": "Por favor, escolha uma opção.",
"describeAnswer": "Por favor, descreva sua resposta.",
"selectAtLeastOne": "Por favor, selecione pelo menos uma opção."
},
"intro": "Ajude-nos a personalizar sua experiência no ComfyUI.",
"options": {
"experience": {
"new": "Novo no ComfyUI",
"pro": "Sou um usuário avançado",
"some": "Já conheço um pouco"
},
"focus": {
"custom_nodes": "Nós personalizados",
"pipelines": "Pipelines automatizados",
"products": "Produtos para outros"
"familiarity": {
"advanced": "Usuário avançado (fluxos de trabalho personalizados)",
"basics": "Confortável com o básico",
"expert": "Especialista (ajuda outras pessoas)",
"new": "Novo no ComfyUI (nunca usei antes)",
"starting": "Começando agora (seguindo tutoriais)"
},
"intent": {
"apps_api": "Apps e APIs",
"exploring": "Só explorando",
"3d_game": "Assets 3D / assets para jogos",
"api": "Endpoints de API para executar workflows",
"apps": "Apps simplificados a partir de workflows",
"audio": "Áudio / música",
"custom_nodes": "Nodes personalizados",
"images": "Imagens",
"other": "Outra coisa",
"otherPlaceholder": "O que você quer criar?",
"video": "Vídeo",
"not_sure": "Não tenho certeza",
"videos": "Vídeos",
"workflows": "Workflows ou pipelines personalizados"
},
"source": {
"community": "Uma comunidade ou fórum",
"conference": "Conferência ou evento",
"discord": "Discord / comunidade",
"friend": "Amigo ou colega",
"other": "Outro",
"otherPlaceholder": "Onde você nos encontrou?",
"search": "Google / busca",
"social": "Mídias sociais"
},
"source_social": {
"discord": "Discord",
"github": "GitHub",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "Newsletter ou blog",
"other": "Outro",
"reddit": "Reddit",
"tiktok": "TikTok",
"twitter": "X (Twitter)",
"search": "Google / busca",
"twitter": "Twitter / X",
"youtube": "YouTube"
},
"usage": {
"education": "Educação (estudante ou educador)",
"personal": "Uso pessoal",
"work": "Trabalho"
}
},
"otherPlaceholder": "Conte-nos mais",
"placeholder": "Espaço reservado para perguntas da pesquisa",
"steps": {
"familiarity": "Qual o seu nível de familiaridade com o ComfyUI?",
"intent": "O que você deseja criar com o ComfyUI?",
"source": "Onde você ouviu falar do ComfyUI?",
"usage": "Como você pretende usar o ComfyUI?"
},
"title": "Pesquisa da Nuvem"
}
},
@@ -573,11 +578,10 @@
"cloudStart_learnAboutButton": "Saiba mais sobre a Nuvem",
"cloudStart_title": "comece a criar em segundos",
"cloudStart_wantToRun": "Prefere rodar o ComfyUI localmente?",
"cloudSurvey_steps_experience": "Qual o seu nível de conhecimento do ComfyUI?",
"cloudSurvey_steps_focus": "O que você está construindo?",
"cloudSurvey_steps_familiarity": "Qual o seu nível de familiaridade com o ComfyUI?",
"cloudSurvey_steps_intent": "O que você deseja criar com o ComfyUI?",
"cloudSurvey_steps_source": "Onde você ouviu falar do ComfyUI?",
"cloudSurvey_steps_source_social": "Em qual plataforma?",
"cloudSurvey_steps_usage": "Como você pretende usar o ComfyUI?",
"cloudWaitlist_contactLink": "aqui",
"cloudWaitlist_questionsText": "Dúvidas? Entre em contato conosco",
"color": {

View File

@@ -515,52 +515,57 @@
},
"survey": {
"errors": {
"answerTooLong": "Пожалуйста, сократите ваш ответ до {max} символов.",
"chooseAnOption": "Пожалуйста, выберите вариант.",
"describeAnswer": "Пожалуйста, опишите ваш ответ.",
"selectAtLeastOne": "Пожалуйста, выберите хотя бы один вариант."
},
"intro": "Помогите нам адаптировать ваш опыт работы с ComfyUI.",
"options": {
"experience": {
"new": "Впервые в ComfyUI",
"pro": "Я опытный пользователь",
"some": "Я немного знаком(а)"
},
"focus": {
"custom_nodes": "Пользовательские узлы",
"pipelines": "Автоматизированные пайплайны",
"products": "Продукты для других"
"familiarity": {
"advanced": "Продвинутый пользователь (пользовательские рабочие процессы)",
"basics": "Уверенно владею основами",
"expert": "Эксперт (помогаю другим)",
"new": "Новичок в ComfyUI (никогда не использовал)",
"starting": "Только начинаю (следую руководствам)"
},
"intent": {
"apps_api": "Приложения и API",
"exploring": "Просто изучаю",
"3d_game": "3D-ассеты / игровые ассеты",
"api": "API endpoints для запуска workflow",
"apps": "Упрощённые приложения из workflow",
"audio": "Аудио / музыка",
"custom_nodes": "Пользовательские node",
"images": "Изображения",
"other": "Другое",
"otherPlaceholder": "Что вы хотите создать?",
"video": "Видео",
"not_sure": "Не уверен",
"videos": "Видео",
"workflows": "Пользовательские workflow или pipeline"
},
"source": {
"community": "Сообщество или форум",
"conference": "Конференция или мероприятие",
"discord": "Discord / сообщество",
"friend": "Друг или коллега",
"other": "Другое",
"otherPlaceholder": "Где вы о нас узнали?",
"search": "Google / поиск",
"social": "Социальные сети"
},
"source_social": {
"discord": "Discord",
"github": "GitHub",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "Новостная рассылка или блог",
"other": "Другое",
"reddit": "Reddit",
"tiktok": "TikTok",
"twitter": "X (Twitter)",
"search": "Google / поиск",
"twitter": "Twitter / X",
"youtube": "YouTube"
},
"usage": {
"education": "Образование (студент или преподаватель)",
"personal": "Личное использование",
"work": "Работа"
}
},
"otherPlaceholder": "Расскажите подробнее",
"placeholder": "Вопросы для опроса",
"steps": {
"familiarity": "Насколько вы знакомы с ComfyUI?",
"intent": "Что вы хотите создавать с помощью ComfyUI?",
"source": "Где вы узнали о ComfyUI?",
"usage": "Как вы планируете использовать ComfyUI?"
},
"title": "Облачный опрос"
}
},
@@ -573,11 +578,10 @@
"cloudStart_learnAboutButton": "Узнать о Cloud",
"cloudStart_title": "начать создавать за секунды",
"cloudStart_wantToRun": "Хотите запустить ComfyUI локально?",
"cloudSurvey_steps_experience": "Насколько хорошо вы знаете ComfyUI?",
"cloudSurvey_steps_focus": "Что вы создаёте?",
"cloudSurvey_steps_familiarity": "Насколько вы знакомы с ComfyUI?",
"cloudSurvey_steps_intent": "Что вы хотите создавать с помощью ComfyUI?",
"cloudSurvey_steps_source": "Где вы узнали о ComfyUI?",
"cloudSurvey_steps_source_social": "На какой платформе?",
"cloudSurvey_steps_usage": "Как вы планируете использовать ComfyUI?",
"cloudWaitlist_contactLink": "здесь",
"cloudWaitlist_questionsText": "Есть вопросы? Свяжитесь с нами",
"color": {

View File

@@ -515,52 +515,57 @@
},
"survey": {
"errors": {
"answerTooLong": "Lütfen cevabınızı {max} karakterin altında tutun.",
"chooseAnOption": "Lütfen bir seçenek seçin.",
"describeAnswer": "Lütfen cevabınızııklayın.",
"selectAtLeastOne": "Lütfen en az bir seçenek seçin."
},
"intro": "ComfyUI deneyiminizi size özel hale getirmemize yardımcı olun.",
"options": {
"experience": {
"new": "ComfyUI'ye yeni",
"pro": "Güçlü bir kullanıcıyım",
"some": "Biraz biliyorum"
},
"focus": {
"custom_nodes": "Özel node'lar",
"pipelines": "Otomatikleştirilmiş pipeline'lar",
"products": "Başkaları için ürünler"
"familiarity": {
"advanced": "İleri seviye kullanıcı (özel iş akışları)",
"basics": "Temel bilgilerde rahatım",
"expert": "Uzman (başkalarına yardım ediyorum)",
"new": "ComfyUI'a yeni (daha önce hiç kullanmadım)",
"starting": "Yeni başlıyorum (eğitimleri takip ediyorum)"
},
"intent": {
"apps_api": "Uygulamalar ve API'ler",
"exploring": "Sadece keşfediyorum",
"3d_game": "3D varlıklar / oyun varlıkları",
"api": "İş akışlarını çalıştırmak için API uç noktaları",
"apps": "İş akışlarından basitleştirilmiş uygulamalar",
"audio": "Ses / müzik",
"custom_nodes": "Özel node'lar",
"images": "Görseller",
"other": "Başka bir şey",
"otherPlaceholder": "Ne yapmak istiyorsunuz?",
"video": "Video",
"not_sure": "Emin değilim",
"videos": "Videolar",
"workflows": "Özel iş akışları veya boru hatları"
},
"source": {
"community": "Bir topluluk veya forum",
"conference": "Konferans veya etkinlik",
"discord": "Discord / topluluk",
"friend": "Arkadaş veya iş arkadaşı",
"other": "Diğer",
"otherPlaceholder": "Bizi nereden buldunuz?",
"search": "Google / arama",
"social": "Sosyal medya"
},
"source_social": {
"discord": "Discord",
"github": "GitHub",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "Bülten veya blog",
"other": "Diğer",
"reddit": "Reddit",
"tiktok": "TikTok",
"twitter": "X (Twitter)",
"search": "Google / arama",
"twitter": "Twitter / X",
"youtube": "YouTube"
},
"usage": {
"education": "Eğitim (öğrenci veya eğitmen)",
"personal": "Kişisel kullanım",
"work": "İş"
}
},
"otherPlaceholder": "Daha fazla bilgi verin",
"placeholder": "Anket soruları yer tutucusu",
"steps": {
"familiarity": "ComfyUI'a ne kadar aşinasınız?",
"intent": "ComfyUI ile ne oluşturmak istiyorsunuz?",
"source": "ComfyUI'yi nereden duydunuz?",
"usage": "ComfyUI'yi nasıl kullanmayı planlıyorsunuz?"
},
"title": "Bulut Anketi"
}
},
@@ -573,11 +578,10 @@
"cloudStart_learnAboutButton": "Cloud hakkında bilgi edinin",
"cloudStart_title": "saniyeler içinde oluşturmaya başlayın",
"cloudStart_wantToRun": "ComfyUI'ı yerel olarak çalıştırmak mı istiyorsunuz?",
"cloudSurvey_steps_experience": "ComfyUI'yi ne kadar iyi biliyorsunuz?",
"cloudSurvey_steps_focus": "Ne inşa ediyorsunuz?",
"cloudSurvey_steps_familiarity": "ComfyUI'ya ne kadar aşinasınız?",
"cloudSurvey_steps_intent": "ComfyUI ile ne oluşturmak istiyorsunuz?",
"cloudSurvey_steps_source": "ComfyUI'yi nereden duydunuz?",
"cloudSurvey_steps_source_social": "Hangi platform?",
"cloudSurvey_steps_usage": "ComfyUI'yi nasıl kullanmayı planlıyorsunuz?",
"cloudWaitlist_contactLink": "burada",
"cloudWaitlist_questionsText": "Sorularınız mı var? Bize ulaşın",
"color": {

View File

@@ -515,52 +515,57 @@
},
"survey": {
"errors": {
"answerTooLong": "請將您的回答控制在 {max} 個字以內。",
"chooseAnOption": "請選擇一個選項。",
"describeAnswer": "請描述您的答案。",
"selectAtLeastOne": "請至少選擇一個選項。"
},
"intro": "協助我們為您量身打造 ComfyUI 體驗。",
"options": {
"experience": {
"new": "ComfyUI 新手",
"pro": "我是進階使用者",
"some": "我已經熟悉操作"
},
"focus": {
"custom_nodes": "自訂節點",
"pipelines": "自動化流程",
"products": "為他人打造產品"
"familiarity": {
"advanced": "進階使用者(自訂工作流程)",
"basics": "熟悉基礎操作",
"expert": "專家(協助他人)",
"new": "ComfyUI 新手(從未使用過)",
"starting": "剛開始(正在跟隨教學)"
},
"intent": {
"apps_api": "應用程式與 API",
"exploring": "只是探索",
"3d_game": "3D 素材/遊戲素材",
"api": "執行工作流程的 API 端點",
"apps": "由工作流程簡化的應用程式",
"audio": "音訊/音樂",
"custom_nodes": "自訂節點",
"images": "圖像",
"other": "其他",
"otherPlaceholder": "您想製作什麼?",
"video": "影片",
"not_sure": "尚未確定",
"videos": "影片",
"workflows": "自訂工作流程或管線"
},
"source": {
"community": "社群或論壇",
"conference": "研討會或活動",
"discord": "Discord社群",
"friend": "朋友或同事",
"other": "其他",
"otherPlaceholder": "您是在哪裡發現我們的?",
"search": "Google搜尋引擎",
"social": "社群媒體"
},
"source_social": {
"discord": "Discord",
"github": "GitHub",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "電子報或部落格",
"other": "其他",
"reddit": "Reddit",
"tiktok": "TikTok",
"twitter": "XTwitter",
"search": "Google搜尋引擎",
"twitter": "Twitter / X",
"youtube": "YouTube"
},
"usage": {
"education": "教育用途(學生或教育者)",
"personal": "個人用途",
"work": "工作用途"
}
},
"otherPlaceholder": "請告訴我們更多",
"placeholder": "問卷問題佔位符",
"steps": {
"familiarity": "您對 ComfyUI 的熟悉程度如何?",
"intent": "您想用 ComfyUI 創作什麼?",
"source": "您是從哪裡得知 ComfyUI 的?",
"usage": "您打算如何使用 ComfyUI"
},
"title": "雲端問卷"
}
},
@@ -573,11 +578,10 @@
"cloudStart_learnAboutButton": "了解雲端服務",
"cloudStart_title": "數秒內開始創作",
"cloudStart_wantToRun": "想要在本機運行 ComfyUI",
"cloudSurvey_steps_experience": "您對 ComfyUI 的熟悉程度?",
"cloudSurvey_steps_focus": "您正在製作什麼?",
"cloudSurvey_steps_familiarity": "您對 ComfyUI 的熟悉程度如何",
"cloudSurvey_steps_intent": "您想用 ComfyUI 創作什麼?",
"cloudSurvey_steps_source": "您是從哪裡得知 ComfyUI 的?",
"cloudSurvey_steps_source_social": "哪個平台",
"cloudSurvey_steps_usage": "您打算如何使用 ComfyUI",
"cloudWaitlist_contactLink": "此處",
"cloudWaitlist_questionsText": "有問題?聯絡我們",
"color": {

View File

@@ -515,52 +515,57 @@
},
"survey": {
"errors": {
"answerTooLong": "请将您的回答控制在 {max} 个字符以内。",
"chooseAnOption": "请选择一个选项。",
"describeAnswer": "请描述您的答案。",
"selectAtLeastOne": "请至少选择一个选项。"
},
"intro": "帮助我们为您定制 ComfyUI 体验。",
"options": {
"experience": {
"new": "ComfyUI 新手",
"pro": "我是高级用户",
"some": "我已经熟悉操作"
},
"focus": {
"custom_nodes": "自定义节点",
"pipelines": "自动化流程",
"products": "为他人制作产品"
"familiarity": {
"advanced": "高级用户(自定义工作流)",
"basics": "熟练掌握基础知识",
"expert": "专家(帮助他人)",
"new": "ComfyUI 新手(从未使用过)",
"starting": "刚刚开始(正在学习教程)"
},
"intent": {
"apps_api": "应用和 API",
"exploring": "只是探索一下",
"3d_game": "3D 资产 / 游戏资产",
"api": "运行工作流的 API 端点",
"apps": "基于工作流的简化应用",
"audio": "音频 / 音乐",
"custom_nodes": "自定义节点",
"images": "图像",
"other": "其他",
"otherPlaceholder": "你想做什么?",
"video": "视频",
"not_sure": "不确定",
"videos": "视频",
"workflows": "自定义工作流或流程"
},
"source": {
"community": "社区或论坛",
"conference": "会议或活动",
"discord": "Discord / 社区",
"friend": "朋友或同事",
"other": "其他",
"otherPlaceholder": "你是从哪里了解到我们的?",
"search": "Google / 搜索",
"social": "社交媒体"
},
"source_social": {
"discord": "Discord",
"github": "GitHub",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "新闻通讯或博客",
"other": "其他",
"reddit": "Reddit",
"tiktok": "TikTok",
"twitter": "X推特",
"search": "Google / 搜索",
"twitter": "Twitter / X",
"youtube": "YouTube"
},
"usage": {
"education": "教育(学生或教师)",
"personal": "个人使用",
"work": "工作"
}
},
"otherPlaceholder": "请告诉我们更多",
"placeholder": "调查问题占位符",
"steps": {
"familiarity": "你对 ComfyUI 有多熟悉?",
"intent": "您希望用 ComfyUI 创作什么?",
"source": "您是从哪里了解到 ComfyUI 的?",
"usage": "您打算如何使用 ComfyUI"
},
"title": "云调研"
}
},
@@ -573,11 +578,10 @@
"cloudStart_learnAboutButton": "了解云服务",
"cloudStart_title": "几秒钟内开始创作",
"cloudStart_wantToRun": "想在本地运行 ComfyUI 吗?",
"cloudSurvey_steps_experience": "你对 ComfyUI 有多了解",
"cloudSurvey_steps_focus": "你正在构建什么?",
"cloudSurvey_steps_familiarity": "你对 ComfyUI 有多熟悉",
"cloudSurvey_steps_intent": "您希望用 ComfyUI 创作什么?",
"cloudSurvey_steps_source": "您是从哪里了解到 ComfyUI 的?",
"cloudSurvey_steps_source_social": "你是在哪个平台上看到的",
"cloudSurvey_steps_usage": "您打算如何使用 ComfyUI",
"cloudWaitlist_contactLink": "这里",
"cloudWaitlist_questionsText": "有问题?联系我们",
"color": {

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

@@ -1,5 +1,7 @@
<template>
<div class="dark-theme flex max-h-full w-full max-w-md flex-col px-4 sm:px-6">
<div
class="dark-theme flex max-h-[85vh] w-full max-w-md flex-col overflow-y-auto px-4 sm:px-6"
>
<h1
class="-mb-1 font-inter text-xl/8 font-semibold tracking-wide text-primary-comfy-canvas sm:text-2xl/8"
>

View File

@@ -1,618 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { reactive } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
/**
* Every test drives a real in-memory router and the real preserved-query
* manager: the tracker strips the code from the URL at capture time, so the
* stash is the only carrier, and redemption fires from router.afterEach, an
* auth watcher, and a delayed retry after a transient failure.
*
* The fake clock (installed for every test) keeps those retry timers from
* leaking into later tests: afterEach discards them with vi.useRealTimers().
*/
const mockConfirm = vi.hoisted(() => vi.fn())
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
confirm: mockConfirm
})
}))
const mockToastAdd = vi.hoisted(() => vi.fn())
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({
add: mockToastAdd
})
}))
interface MockAuthStore {
currentUser: {
uid: string
getIdToken: (forceRefresh?: boolean) => Promise<string>
} | null
getIdToken: () => Promise<string>
}
const mockUserGetIdToken = vi.hoisted(() => vi.fn())
const mockStoreGetIdToken = vi.hoisted(() => vi.fn())
// Reactive so the module's watcher on currentUser fires without a navigation.
// The mock factory is cached across vi.resetModules(), so it reads a holder
// refilled per test; watchers leaked by earlier module generations stay
// subscribed to earlier stores and remain dormant.
const authStoreHolder = vi.hoisted(() => ({
store: null as MockAuthStore | null
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => authStoreHolder.store
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/scripts/api', () => ({
api: {
apiURL: (path: string) => `/api${path}`
}
}))
const VALID_CODE = `dlc_${'A'.repeat(43)}`
const SECOND_CODE = `dlc_${'B'.repeat(43)}`
const REDEEM_URL = '/api/auth/desktop-login-codes/redeem'
const NAMESPACE = 'desktop_login'
const STORAGE_KEY = 'Comfy.PreservedQuery.desktop_login'
const RETRY_DELAY_MS = 5_000
const mockFetch = vi.fn()
let mockAuthStore: MockAuthStore
function okResponse() {
return new Response(JSON.stringify({ status: 'redeemed' }), { status: 200 })
}
function expectedFetchOptions(code: string) {
return {
method: 'POST',
headers: {
Authorization: 'Bearer firebase-id-token',
'Content-Type': 'application/json'
},
body: JSON.stringify({ code }),
signal: expect.any(AbortSignal)
}
}
// The triggers fire-and-forget the redemption; a zero-length advance of the
// fake clock yields the event loop so the whole mocked promise chain settles.
async function flushRedemption() {
await vi.advanceTimersByTimeAsync(0)
}
// vi.resetModules() also resets the preserved-query manager's in-memory map,
// so the manager must be imported alongside the module under test.
async function setup() {
const { installDesktopLoginRedemption } =
await import('./desktopLoginRedemption')
const { capturePreservedQuery, getPreservedQueryParam } =
await import('@/platform/navigation/preservedQueryManager')
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/:pathMatch(.*)*', component: { template: '<div />' } }]
})
installDesktopLoginRedemption(router)
let navigationCount = 0
const trigger = async () => {
await router.push(`/trigger-${navigationCount++}`)
await flushRedemption()
}
return {
router,
trigger,
seedStash: (code: string) =>
capturePreservedQuery(NAMESPACE, { desktop_login_code: code }, [
'desktop_login_code'
]),
stashedCode: () => getPreservedQueryParam(NAMESPACE, 'desktop_login_code')
}
}
describe('installDesktopLoginRedemption', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
vi.useFakeTimers()
sessionStorage.clear()
vi.stubGlobal('fetch', mockFetch)
vi.spyOn(console, 'warn').mockImplementation(() => {})
mockFetch.mockReset()
mockConfirm.mockResolvedValue(true)
mockUserGetIdToken.mockResolvedValue('firebase-id-token')
mockAuthStore = reactive({
currentUser: {
uid: 'user-1',
getIdToken: mockUserGetIdToken
},
getIdToken: mockStoreGetIdToken
})
authStoreHolder.store = mockAuthStore
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('does nothing on navigation when no code is stashed', async () => {
const { trigger } = await setup()
await trigger()
expect(mockConfirm).not.toHaveBeenCalled()
expect(mockFetch).not.toHaveBeenCalled()
expect(mockToastAdd).not.toHaveBeenCalled()
})
it('redeems a stashed code once on navigation with the Firebase bearer token after approval', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch.mockResolvedValue(okResponse())
await trigger()
expect(mockConfirm).toHaveBeenCalledTimes(1)
expect(mockConfirm).toHaveBeenCalledWith({
title: 'desktopLogin.confirmSummary',
message: 'desktopLogin.confirmMessage'
})
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledWith(
REDEEM_URL,
expectedFetchOptions(VALID_CODE)
)
expect(stashedCode()).toBeUndefined()
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'success',
summary: 'desktopLogin.successSummary',
detail: 'desktopLogin.successDetail',
life: 4000
})
})
it('does not fetch before the user approves the confirmation dialog', async () => {
const { trigger, seedStash } = await setup()
seedStash(VALID_CODE)
let approve!: (value: boolean) => void
mockConfirm.mockReturnValue(
new Promise<boolean>((resolve) => {
approve = resolve
})
)
mockFetch.mockResolvedValue(okResponse())
await trigger()
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(1))
expect(mockFetch).not.toHaveBeenCalled()
approve(true)
await flushRedemption()
expect(mockFetch).toHaveBeenCalledTimes(1)
})
it.for([
['declines', false],
['dismisses', null]
] as const)(
'clears the stash without a request or toast when the user %s the dialog',
async ([_label, confirmResult]) => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockConfirm.mockResolvedValue(confirmResult)
await trigger()
expect(mockFetch).not.toHaveBeenCalled()
expect(stashedCode()).toBeUndefined()
expect(mockToastAdd).not.toHaveBeenCalled()
// Declining is final for that code: re-capturing it never re-prompts.
seedStash(VALID_CODE)
await trigger()
expect(mockConfirm).toHaveBeenCalledTimes(1)
expect(mockFetch).not.toHaveBeenCalled()
expect(stashedCode()).toBeUndefined()
}
)
it('asks for approval at most once per code across transient retries', async () => {
const { trigger, seedStash } = await setup()
seedStash(VALID_CODE)
mockFetch.mockResolvedValue(new Response(null, { status: 500 }))
await trigger()
expect(mockFetch).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
expect(mockConfirm).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledTimes(2)
})
it('redeems a code hydrated lazily from sessionStorage', async () => {
const { trigger } = await setup()
sessionStorage.setItem(
STORAGE_KEY,
JSON.stringify({ desktop_login_code: VALID_CODE })
)
mockFetch.mockResolvedValue(okResponse())
await trigger()
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledWith(
REDEEM_URL,
expectedFetchOptions(VALID_CODE)
)
})
it('does not redeem or prompt again after a successful redemption', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch.mockResolvedValue(okResponse())
await trigger()
expect(mockFetch).toHaveBeenCalledTimes(1)
// A later navigation re-captures the already-redeemed code.
seedStash(VALID_CODE)
await trigger()
expect(mockConfirm).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(stashedCode()).toBeUndefined()
})
it.for([400, 403, 404, 409, 410])(
'clears the stash, shows an error toast, and never retries on %s',
async (status) => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch.mockResolvedValue(new Response(null, { status }))
await trigger()
expect(stashedCode()).toBeUndefined()
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'desktopLogin.expiredSummary',
detail: 'desktopLogin.expiredDetail',
life: 6000
})
}
)
it.for([401, 500])(
'keeps the stash on %s for the scheduled retry without a toast',
async (status) => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch.mockResolvedValue(new Response(null, { status }))
await trigger()
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(stashedCode()).toBe(VALID_CODE)
expect(mockToastAdd).not.toHaveBeenCalled()
}
)
it('retries once by itself, then clears the stash and shows an error toast when the budget is spent', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch.mockResolvedValue(new Response(null, { status: 500 }))
await trigger()
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(stashedCode()).toBe(VALID_CODE)
expect(mockToastAdd).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
expect(mockFetch).toHaveBeenCalledTimes(2)
expect(stashedCode()).toBeUndefined()
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'desktopLogin.failedSummary',
detail: 'desktopLogin.failedDetail',
life: 6000
})
})
it('forces a token refresh on the retry after a 401', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch
.mockResolvedValueOnce(new Response(null, { status: 401 }))
.mockResolvedValueOnce(okResponse())
await trigger()
expect(mockUserGetIdToken).toHaveBeenLastCalledWith(false)
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
expect(mockFetch).toHaveBeenCalledTimes(2)
expect(mockUserGetIdToken).toHaveBeenLastCalledWith(true)
expect(stashedCode()).toBeUndefined()
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
it('passes a timeout signal and treats an aborted request as transient', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch.mockRejectedValue(
new DOMException('The operation timed out.', 'TimeoutError')
)
await trigger()
expect(mockFetch).toHaveBeenCalledWith(
REDEEM_URL,
expectedFetchOptions(VALID_CODE)
)
expect(stashedCode()).toBe(VALID_CODE)
expect(mockToastAdd).not.toHaveBeenCalled()
})
it('treats an id token failure as transient without a toast', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockUserGetIdToken.mockRejectedValue(new Error('firebase unavailable'))
await trigger()
expect(mockFetch).not.toHaveBeenCalled()
// authStore.getIdToken surfaces failures through a modal error dialog,
// which this background flow must never trigger.
expect(mockStoreGetIdToken).not.toHaveBeenCalled()
expect(stashedCode()).toBe(VALID_CODE)
expect(mockToastAdd).not.toHaveBeenCalled()
})
it('clears the stash without a dialog or request for a malformed code', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash('not-a-desktop-login-code')
await trigger()
expect(mockConfirm).not.toHaveBeenCalled()
expect(mockFetch).not.toHaveBeenCalled()
expect(stashedCode()).toBeUndefined()
})
it('contains an unexpected internal error instead of rejecting', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const { trigger, seedStash } = await setup()
seedStash(VALID_CODE)
mockConfirm.mockRejectedValue(new Error('dialog exploded'))
await expect(trigger()).resolves.toBeUndefined()
expect(consoleError).toHaveBeenCalledWith(
'[DesktopLoginRedemption] Redemption failed:',
expect.any(Error)
)
expect(mockToastAdd).not.toHaveBeenCalled()
})
it('keeps the stash while unauthenticated and redeems via the auth watcher once a session appears', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockAuthStore.currentUser = null
mockFetch.mockResolvedValue(okResponse())
// The first completed navigation installs the watcher; without a session
// nothing redeems and the stash is kept.
await trigger()
expect(mockConfirm).not.toHaveBeenCalled()
expect(mockFetch).not.toHaveBeenCalled()
expect(stashedCode()).toBe(VALID_CODE)
// A session appearing without any further navigation redeems via the
// watcher.
mockAuthStore.currentUser = {
uid: 'user-1',
getIdToken: mockUserGetIdToken
}
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1))
expect(mockFetch).toHaveBeenCalledWith(
REDEEM_URL,
expectedFetchOptions(VALID_CODE)
)
expect(stashedCode()).toBeUndefined()
})
it.for([
['succeeded', () => mockFetch.mockResolvedValueOnce(okResponse())],
['was declined', () => mockConfirm.mockResolvedValueOnce(false)]
] as const)(
'gives a second code its own dialog and request after the first code %s',
async ([_label, arrangeFirstOutcome]) => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
arrangeFirstOutcome()
await trigger()
expect(mockConfirm).toHaveBeenCalledTimes(1)
seedStash(SECOND_CODE)
mockFetch.mockResolvedValue(okResponse())
await trigger()
expect(mockConfirm).toHaveBeenCalledTimes(2)
expect(mockFetch).toHaveBeenLastCalledWith(
REDEEM_URL,
expect.objectContaining({ body: JSON.stringify({ code: SECOND_CODE }) })
)
expect(stashedCode()).toBeUndefined()
}
)
it('gives a second code a fresh attempt budget after the first code exhausted its own', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch.mockResolvedValue(new Response(null, { status: 500 }))
await trigger()
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
expect(mockFetch).toHaveBeenCalledTimes(2)
expect(stashedCode()).toBeUndefined()
seedStash(SECOND_CODE)
await trigger()
expect(mockFetch).toHaveBeenCalledTimes(3)
expect(stashedCode()).toBe(SECOND_CODE)
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
expect(mockFetch).toHaveBeenCalledTimes(4)
expect(stashedCode()).toBeUndefined()
})
it('re-asks for approval when the account changes after approval and redeems with the new account token', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch
.mockResolvedValueOnce(new Response(null, { status: 500 }))
.mockResolvedValueOnce(okResponse())
// user-1 approves; the redeem fails transiently, keeping the code stashed.
await trigger()
expect(mockConfirm).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(stashedCode()).toBe(VALID_CODE)
// The session changes to user-2 before the retry: user-1's approval must
// not authorize redeeming with user-2's token.
mockAuthStore.currentUser = {
uid: 'user-2',
getIdToken: vi.fn().mockResolvedValue('second-user-token')
}
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(2))
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2))
expect(mockFetch).toHaveBeenLastCalledWith(
REDEEM_URL,
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer second-user-token'
})
})
)
expect(stashedCode()).toBeUndefined()
})
it('re-prompts and redeems under the new account when the session changes while the approval dialog is open', async () => {
const { seedStash, stashedCode, trigger } = await setup()
seedStash(VALID_CODE)
let approve!: (value: boolean) => void
mockConfirm.mockReturnValueOnce(
new Promise<boolean>((resolve) => {
approve = resolve
})
)
mockFetch.mockResolvedValue(okResponse())
await trigger()
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(1))
// The session swaps to user-2 while user-1's dialog is open: the stale
// approval must not redeem, and the raced auth trigger is replayed to
// re-prompt under user-2 without another navigation.
const secondUser = {
uid: 'user-2',
getIdToken: vi.fn().mockResolvedValue('second-user-token')
}
mockAuthStore.currentUser = secondUser
await flushRedemption()
approve(true)
await flushRedemption()
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(2))
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1))
expect(mockFetch).toHaveBeenCalledWith(
REDEEM_URL,
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer second-user-token'
})
})
)
expect(stashedCode()).toBeUndefined()
})
it.for([
['succeeds', () => okResponse()],
['fails terminally', () => new Response(null, { status: 404 })]
] as const)(
'processes a newer code stashed mid-flight after the older redemption %s',
async ([_label, firstResponse]) => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
let resolveFirstFetch!: (response: Response) => void
mockFetch.mockReturnValueOnce(
new Promise<Response>((resolve) => {
resolveFirstFetch = resolve
})
)
await trigger()
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1))
// A second code arrives while the first redemption is in flight; it
// must survive the first's settlement and be processed right after.
seedStash(SECOND_CODE)
mockFetch.mockResolvedValue(okResponse())
resolveFirstFetch(firstResponse())
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2))
expect(mockConfirm).toHaveBeenCalledTimes(2)
expect(mockFetch).toHaveBeenLastCalledWith(
REDEEM_URL,
expect.objectContaining({ body: JSON.stringify({ code: SECOND_CODE }) })
)
expect(stashedCode()).toBeUndefined()
}
)
it('coalesces concurrent triggers into one dialog and one request', async () => {
const { router, seedStash } = await setup()
seedStash(VALID_CODE)
let approve!: (value: boolean) => void
mockConfirm.mockReturnValue(
new Promise<boolean>((resolve) => {
approve = resolve
})
)
mockFetch.mockResolvedValue(okResponse())
await router.push('/burst-1')
await router.push('/burst-2')
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(1))
approve(true)
await flushRedemption()
expect(mockConfirm).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledTimes(1)
})
})

View File

@@ -1,263 +0,0 @@
import { watch } from 'vue'
import type { Router } from 'vue-router'
import { t } from '@/i18n'
import {
clearPreservedQuery,
getPreservedQueryParam
} from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { useDialogService } from '@/services/dialogService'
import { useAuthStore } from '@/stores/authStore'
const NAMESPACE = PRESERVED_QUERY_NAMESPACES.DESKTOP_LOGIN
const DESKTOP_LOGIN_CODE_KEY = 'desktop_login_code'
// The backend issues "dlc_" + 43 base64url chars; bounds are loose so the
// backend stays the authority on exact code length.
const DESKTOP_LOGIN_CODE_PATTERN = /^dlc_[A-Za-z0-9_-]{20,256}$/
// Statuses that mean the desktop app must start a fresh sign-in, so the code
// is dropped. 401 stays transient: the session may still be settling.
const TERMINAL_REDEEM_STATUSES = new Set([400, 403, 404, 409, 410])
// One delayed in-page retry, so an approved sign-in always reaches a success
// or failure toast without ever looping within a page load.
const MAX_REDEEM_ATTEMPTS = 2
const RETRY_DELAY_MS = 5_000
// Abort the redeem request if the backend hangs; treated as transient.
const REDEEM_TIMEOUT_MS = 10_000
interface CodeRedemptionState {
attempts: number
approvedUserUid: string | null
settled: boolean
forceTokenRefresh: boolean
}
// Keyed by code so a different code arriving later gets its own approval and
// attempt budget, while retries of the same code reuse both.
const codeStates = new Map<string, CodeRedemptionState>()
// Coalesces concurrent triggers into one drain; a trigger arriving mid-drain
// (e.g. the auth watcher firing while the dialog is open) is replayed as one
// more pass instead of being dropped.
let draining = false
let retriggerRequested = false
let authWatcherInstalled = false
function getCodeState(code: string): CodeRedemptionState {
const existing = codeStates.get(code)
if (existing) return existing
const fresh = {
attempts: 0,
approvedUserUid: null,
settled: false,
forceTokenRefresh: false
}
codeStates.set(code, fresh)
return fresh
}
// A newer code can be stashed while an older one is mid-redemption; settling
// the older one must not wipe it.
function clearStashIfHolds(code: string): void {
if (getPreservedQueryParam(NAMESPACE, DESKTOP_LOGIN_CODE_KEY) === code) {
clearPreservedQuery(NAMESPACE)
}
}
function settle(code: string, state: CodeRedemptionState): void {
state.settled = true
clearStashIfHolds(code)
}
function handleTransientFailure(
code: string,
state: CodeRedemptionState,
reason: string
): void {
console.warn(`[DesktopLoginRedemption] Redeem request failed: ${reason}`)
if (state.attempts < MAX_REDEEM_ATTEMPTS) {
// attempts only increments, so this branch runs at most once per code
// and cannot stack retry timers.
setTimeout(() => {
void redeemPendingDesktopLoginCode()
}, RETRY_DELAY_MS)
return
}
// Budget spent: drop the code and tell the user instead of failing silently.
settle(code, state)
useToastStore().add({
severity: 'error',
summary: t('desktopLogin.failedSummary'),
detail: t('desktopLogin.failedDetail'),
life: 6000
})
}
// Explicit approval defeats device-code phishing: a lured click on a leaked
// link must not bind the victim's session to an attacker's desktop app.
// Approval is per code *and* account.
async function confirmRedemption(
state: CodeRedemptionState,
uid: string
): Promise<boolean> {
if (state.approvedUserUid === uid) return true
const confirmed = await useDialogService().confirm({
title: t('desktopLogin.confirmSummary'),
message: t('desktopLogin.confirmMessage')
})
if (confirmed !== true) return false
state.approvedUserUid = uid
return true
}
async function redeemCode(code: string): Promise<void> {
const state = getCodeState(code)
if (state.settled) {
// A later navigation can re-capture an already-settled code; drop it.
clearStashIfHolds(code)
return
}
// No session yet (e.g. code captured on the login page): keep the stash and
// let a post-login trigger redeem it.
const user = useAuthStore().currentUser
if (!user) return
if (!(await confirmRedemption(state, user.uid))) {
// Declined/dismissed: drop the code without an error.
settle(code, state)
return
}
// Approval binds the code to one account: if the session changed while the
// dialog was open, keep the code stashed and let the (replayed) auth-change
// trigger re-prompt under the now-current account.
const approvedUser = useAuthStore().currentUser
if (!approvedUser || approvedUser.uid !== state.approvedUserUid) return
state.attempts++
// Token comes straight from the Firebase user: authStore.getIdToken()
// surfaces failures through a modal dialog this background flow must avoid.
let idToken: string
try {
idToken = await approvedUser.getIdToken(state.forceTokenRefresh)
} catch {
handleTransientFailure(code, state, 'could not get id token')
return
}
let response: Response
try {
response = await fetch(api.apiURL('/auth/desktop-login-codes/redeem'), {
method: 'POST',
headers: {
Authorization: `Bearer ${idToken}`,
'Content-Type': 'application/json'
},
// TODO(@comfyorg/ingest-types): type the payload with the generated
// request type once the desktop-login-codes openapi addition propagates.
body: JSON.stringify({ code }),
signal: AbortSignal.timeout(REDEEM_TIMEOUT_MS)
})
} catch (error) {
handleTransientFailure(
code,
state,
error instanceof Error && error.name === 'TimeoutError'
? 'request timed out'
: 'network error'
)
return
}
if (response.ok) {
settle(code, state)
useToastStore().add({
severity: 'success',
summary: t('desktopLogin.successSummary'),
detail: t('desktopLogin.successDetail'),
life: 4000
})
return
}
if (TERMINAL_REDEEM_STATUSES.has(response.status)) {
settle(code, state)
useToastStore().add({
severity: 'error',
summary: t('desktopLogin.expiredSummary'),
detail: t('desktopLogin.expiredDetail'),
life: 6000
})
return
}
// A 401 usually means a stale cached id token; mint a fresh one on retry.
if (response.status === 401) state.forceTokenRefresh = true
handleTransientFailure(code, state, `status ${response.status}`)
}
async function redeemPendingDesktopLoginCode(): Promise<void> {
// Never rejects: the triggers fire-and-forget this.
if (draining) {
retriggerRequested = true
return
}
draining = true
try {
do {
retriggerRequested = false
const code = getPreservedQueryParam(NAMESPACE, DESKTOP_LOGIN_CODE_KEY)
if (!code) continue
if (!DESKTOP_LOGIN_CODE_PATTERN.test(code)) {
clearPreservedQuery(NAMESPACE)
continue
}
await redeemCode(code)
if (code !== getPreservedQueryParam(NAMESPACE, DESKTOP_LOGIN_CODE_KEY))
retriggerRequested = true
} while (retriggerRequested)
} catch (error) {
console.error('[DesktopLoginRedemption] Redemption failed:', error)
} finally {
draining = false
}
}
function installAuthWatcherOnce(): void {
if (authWatcherInstalled) return
authWatcherInstalled = true
// A session can appear without a navigation (e.g. dialog-based sign-in).
// Installed lazily because pinia is not active when router.ts evaluates.
watch(
() => useAuthStore().currentUser,
() => {
void redeemPendingDesktopLoginCode()
}
)
}
/**
* Redeems desktop login codes (`?desktop_login_code=dlc_...`).
*
* The desktop app opens the browser with an opaque one-time code and polls
* the cloud backend; redeeming the code from a signed-in browser session,
* with the user's approval, releases a one-time custom token to that poll
* and signs the desktop app in. The preserved-query tracker (configured in
* router.ts) strips the code from the URL at capture time, so the stash is
* the only place it lives.
*/
export function installDesktopLoginRedemption(router: Router): void {
router.afterEach(() => {
installAuthWatcherOnce()
void redeemPendingDesktopLoginCode()
})
}

View File

@@ -13,7 +13,7 @@
</div>
<div
class="max-h-[45vh] overflow-y-auto transition-[height] duration-300 ease-out sm:max-h-[55vh]"
class="overflow-hidden transition-[height] duration-300 ease-out"
:style="animatedHeightStyle"
>
<div ref="questionContent" class="relative">

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

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

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

@@ -233,5 +233,12 @@ 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')
})
})
})

View File

@@ -27,13 +27,14 @@ 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]',
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 {
@@ -175,16 +176,30 @@ 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')
)
}
@@ -245,7 +260,9 @@ export function useSettingUI(
aboutPanel,
creditsPanel,
userPanel,
...(shouldShowWorkspacePanel.value ? [workspacePanel] : []),
...(shouldShowWorkspacePanel.value
? [planCreditsPanel, membersPanel]
: []),
keybindingPanel,
extensionPanel,
...(isDesktop ? [serverConfigPanel] : []),
@@ -295,8 +312,13 @@ export function useSettingUI(
key: 'workspace',
label: 'Workspace',
children: [
...(shouldShowWorkspacePanel.value ? [workspacePanel.node] : []),
...(isLoggedIn.value &&
...(shouldShowWorkspacePanel.value
? [planCreditsPanel.node, membersPanel.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

@@ -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 BillingStatusBanner from '@/platform/workspace/components/dialogs/settings/BillingStatusBanner.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 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,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,315 @@
<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>
<TableRow v-if="pagedItems.length === 0" class="hover:bg-transparent">
<TableCell
:colspan="5"
class="py-6 text-center text-sm text-muted-foreground"
>
{{ $t('workspacePanel.activity.empty') }}
</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. Hidden until the
API can supply the upcoming amount. -->
<div
v-if="!isPaused && nextInvoiceCents !== null"
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()
function formatWholeUsd(cents: number | null): string {
if (cents === null) return ''
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,397 @@
<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">
{{ inactiveTitle }}
</span>
<span class="text-sm text-muted-foreground">
{{ inactiveSubtitle }}
</span>
</div>
<div v-if="canManageBilling" class="flex shrink-0 items-center gap-2">
<Button variant="secondary" size="lg" @click="manageSubscription">
{{ $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" @click="manageSubscription">
{{ $t('workspacePanel.overview.managePayment') }}
</Button>
<Button
v-if="isOriginalOwner"
variant="secondary"
size="lg"
@click="handleChangePlan"
>
{{ $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 { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
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,
manageSubscription,
showSubscriptionDialog
} = useBillingContext()
const { showCancelSubscriptionDialog } = useDialogService()
const { showPricingTable } = useSubscriptionDialog()
function handleChangePlan() {
if (isFreeTier.value) showPricingTable({ reason: 'settings_billing_panel' })
else showSubscriptionDialog({ reason: 'settings_billing_panel' })
}
// 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()
// Enterprise workspaces read "enterprise" in the lapsed copy, not "team".
const inactiveTitle = computed(() =>
plan.value.name === 'Enterprise'
? t('workspacePanel.overview.inactive.titleEnterprise')
: t('workspacePanel.overview.inactive.title')
)
const inactiveSubtitle = computed(() =>
plan.value.name === 'Enterprise'
? t('workspacePanel.overview.inactive.subtitleEnterprise')
: t('workspacePanel.overview.inactive.subtitle')
)
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,55 @@
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)
// Also observe the table itself: async-loaded rows change the table's
// height without resizing the container, so the initial measure (taken
// against an empty state or fallback row height) would otherwise stick.
// Re-measuring converges — pageSize stabilizes once real rows exist.
const table = el.querySelector('table')
if (table) observer.observe(table)
},
{ immediate: true }
)
onScopeDispose(() => observer?.disconnect())
return { pageSize }
}

View File

@@ -0,0 +1,96 @@
import { computed, reactive } from 'vue'
import { creditsToCents } from '@/base/credits/comfyCredits'
interface AutoReloadConfig {
configured: boolean
enabled: boolean
thresholdCredits: number
reloadCredits: number
// null = no monthly budget set
monthlyBudgetCents: number | null
spentThisCycleCents: number
}
// TODO(auto-reload endpoint): there is no auto-reload API yet, so the config
// is client-side state — it starts unconfigured and edits don't persist
// across reloads.
const config = reactive<AutoReloadConfig>({
configured: false,
enabled: false,
thresholdCredits: 1000,
reloadCredits: 5000,
monthlyBudgetCents: null,
spentThisCycleCents: 0
})
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,
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,126 @@
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
}
// TODO(usage-activity endpoint): there is no per-workspace activity API yet;
// the ledger renders its empty state until events arrive from the backend.
const events: ActivityEvent[] = []
export type ActivitySortField =
| 'date'
| 'user'
| 'eventType'
| 'detail'
| 'credits'
export function useWorkspaceActivity(
search: MaybeRefOrGetter<string>,
pageSize: MaybeRefOrGetter<number>,
selfName: MaybeRefOrGetter<string | null> = null
) {
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; credit inflows stay workspace-level.
const base = computed<ActivityEvent[]>(() => {
const self = toValue(selfName)
if (!self) return events
return events.filter((event) => event.credited || 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,9 @@
import { computed } from 'vue'
// TODO(billing-history endpoint): the workspace billing API does not expose
// the upcoming invoice amount yet; the Invoices banner stays hidden until it
// does. Invoice history itself lives in the Stripe portal by design.
export function useWorkspaceInvoices() {
const nextInvoiceCents = computed<number | null>(() => null)
return { nextInvoiceCents }
}

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()

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