Compare commits

..

5 Commits

Author SHA1 Message Date
huang47
838d02b1d4 ci: compare exact critical unit coverage artifacts 2026-07-09 20:24:05 -07:00
huang47
1b02fe3e15 ci: publish critical unit coverage baseline 2026-07-09 20:23:18 -07:00
Maanil Verma
ceb5ae1eba fix(cloud): keep survey footer visible on small screens (#13568)
## Summary

Keep the onboarding survey's Back/Submit footer visible on small screens
by scrolling only the question area instead of the whole survey.

## Changes

- **What**: The survey scrolled as a single block , so on short
viewports the button row slid under the template footer (Terms/Privacy).
Now the outer is bounded to its slot and the question wrapper in
`DynamicSurveyForm` scrolls internally with a responsive cap , so option
lists scroll while the footer buttons stay pinned. The step height
animation is unchanged.

## Screen Recording

https://github.com/user-attachments/assets/6d7f6d50-59b8-4dc0-b20e-8f4ca08167c6
2026-07-10 08:30:50 +05:30
Comfy Org PR Bot
9f880c78cb 1.48.1 (#13559)
Patch version increment to 1.48.1

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-07-10 02:16:50 +00:00
Benjamin Lu
3b2eb50f3b feat(cloud): redeem desktop login codes for web-to-desktop identity stitching (GTM-93) (#13418)
## What

Browser half of GTM-93 macOS web→desktop identity stitching: when the
desktop app opens the system browser at cloud login with
`?desktop_login_code=dlc_…`, the frontend redeems that code against the
cloud backend once a Firebase session exists — after **explicit user
approval**.

Reworked on top of the preserved-query `stripAfterCapture` capability
(#13465):

- The `DESKTOP_LOGIN` namespace opts into strip-on-capture: the code is
stashed and removed from the URL **before any navigation completes**, so
it never reaches history, `previousFullPath`, later guards, or telemetry
— the hand-rolled URL scrubbing this PR previously carried (raw-string
parser + three strip sites) is gone.
- `desktopLoginRedemption.ts` is a plain module with a single export,
`installDesktopLoginRedemption(router)`, installed once in `router.ts`'s
cloud block (replaces six per-view/composable trigger sites). Redemption
reads the code only from the stash: per-code state (approval + 2-attempt
transient budget, so a second code gets its own approval and budget),
approval dialog, `POST /api/auth/desktop-login-codes/redeem` with the
raw Firebase ID token (backend route is Firebase-JWT-only), 10s fetch
timeout.
- Triggers: `router.afterEach` (the cloud auth guard settles Firebase
init before navigations complete) plus a lazy watcher on
`authStore.currentUser` for sessions that appear without a navigation
(OAuth-resume error branch, dialog sign-in). One bounded in-page retry
(5s) guarantees an approved sign-in always ends in a success or failure
toast.
- Terminal rejections (400/403/404/409/410) drop the code with an error
toast; transient failures (401/5xx/timeout/network) retry once; budget
exhaustion now surfaces a failure toast instead of dying silently.

## Why

Windows stitches web→desktop at download time via installer stamping;
macOS DMGs can't be stamped, so we stitch at login. The browser is where
both halves meet: the existing `posthog.identify(uid)` merges the web
anon person into the Firebase uid, and the backend emits
`comfy.cloud.identity.login_attributed` (uid ↔ installation_id) at
redeem. The desktop app polls the backend and receives a one-time custom
token — no auth material posted to a desktop loopback server (the
concern that stalled #12983, which this supersedes).

## Security

- **Approval dialog before redeem** — redemption mints the desktop a
sign-in token for *your* account, so a lured click must not be enough
(device-code phishing mitigation). Cancel clears the stash and does
nothing.
- Only the opaque single-use code ever appears in a URL; the tracker
strips it on first sight, pre-navigation, and it is never logged.

## Testing

Vitest, driven through a real router (createRouter/createMemoryHistory,
no vue-router mocks) and the real preserved-query manager: capture/stash
lifecycle, approval gate (no fetch before approve; decline/dismiss
clears; per-code approval), Bearer/body shape, terminal-vs-transient
statuses, timeout abort, bounded in-page retry + failure toast on budget
exhaustion, per-code regressions (second code after
success/decline/exhaustion redeems independently), auth-watcher trigger
(session appearing without navigation), unauthenticated no-op, trigger
coalescing. Typecheck/lint/format clean.

Types are hand-written with a `TODO(@comfyorg/ingest-types)` — the
generated types land automatically once the cloud PR merges and the
type-gen workflow runs.

## Landing order

1. Cloud backend: https://github.com/Comfy-Org/cloud/pull/4736 (until it
ships, redemption never triggers — this PR is inert)
2. #13465 preserved-query strip-on-capture (base of this PR)
3. #13466 global-prompt FIFO queue (runtime dependency: the approval
confirm must settle even if another prompt is open)
4. **This PR**
5. Desktop (activates the flow):
https://github.com/Comfy-Org/Comfy-Desktop/pull/1222

GTM-93 · Supersedes #12983

---------

Co-authored-by: AustinMroz <austin@comfy.org>
2026-07-10 02:11:00 +00:00
82 changed files with 2615 additions and 1985 deletions

View File

@@ -8,6 +8,10 @@ inputs:
head-sha:
description: The commit SHA to find runs for
required: true
event:
description: Optional workflow event to match
required: false
default: ''
not-found-status:
description: Status to output when no run exists
required: false
@@ -33,6 +37,7 @@ runs:
env:
WORKFLOW_ID: ${{ inputs.workflow-id }}
HEAD_SHA: ${{ inputs.head-sha }}
EVENT_NAME: ${{ inputs.event }}
NOT_FOUND_STATUS: ${{ inputs.not-found-status }}
with:
github-token: ${{ inputs.token }}
@@ -42,6 +47,7 @@ runs:
repo: context.repo.repo,
workflow_id: process.env.WORKFLOW_ID,
head_sha: process.env.HEAD_SHA,
event: process.env.EVENT_NAME || undefined,
per_page: 1,
});

View File

@@ -38,6 +38,18 @@ jobs:
- name: Run Vitest tests with coverage
run: pnpm test:coverage
- name: Generate critical unit coverage artifact
run: pnpm coverage:critical:extract
- name: Upload critical unit coverage artifact
if: github.event_name != 'merge_group'
uses: actions/upload-artifact@v6
with:
name: critical-unit-coverage-${{ github.sha }}
path: coverage/critical-unit-coverage.json
retention-days: 30
if-no-files-found: error
- name: Upload unit coverage artifact
if: always() && github.event_name == 'push'
uses: actions/upload-artifact@v6
@@ -55,3 +67,79 @@ jobs:
flags: unit
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
critical-unit-regression:
needs: test
if: ${{ github.event_name == 'pull_request' && needs.test.result == 'success' }}
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
steps:
- uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Download head critical unit coverage
uses: actions/download-artifact@v7
with:
name: critical-unit-coverage-${{ github.sha }}
path: temp/head-critical-coverage
- name: Find base unit coverage run
id: find-base-unit
uses: ./.github/actions/find-workflow-run
with:
workflow-id: ci-tests-unit.yaml
head-sha: ${{ github.event.pull_request.base.sha }}
event: push
token: ${{ secrets.GITHUB_TOKEN }}
- name: Require base unit coverage run
if: steps.find-base-unit.outputs.status != 'ready'
run: |
echo "Critical unit baseline artifact unavailable for base ${{ github.event.pull_request.base.sha }}." >> "$GITHUB_STEP_SUMMARY"
echo "Status: ${{ steps.find-base-unit.outputs.status }}" >> "$GITHUB_STEP_SUMMARY"
exit 1
- name: Download base critical unit coverage
continue-on-error: true
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
name: critical-unit-coverage-${{ github.event.pull_request.base.sha }}
run_id: ${{ steps.find-base-unit.outputs.run-id }}
path: temp/base-critical-coverage
if_no_artifact_found: warn
- name: Download base unit LCOV fallback
continue-on-error: true
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
name: unit-coverage
run_id: ${{ steps.find-base-unit.outputs.run-id }}
path: temp/base-unit-coverage
if_no_artifact_found: warn
- name: Prepare base critical unit coverage fallback
run: |
if [ -f temp/base-critical-coverage/critical-unit-coverage.json ]; then
exit 0
fi
if [ ! -f temp/base-unit-coverage/lcov.info ]; then
echo "Critical unit baseline artifact missing for base ${{ github.event.pull_request.base.sha }}." >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
pnpm coverage:critical:extract \
--input temp/base-unit-coverage/lcov.info \
--output temp/base-critical-coverage/critical-unit-coverage.json \
--sha=${{ github.event.pull_request.base.sha }}
- name: Compare critical unit coverage
run: >
pnpm coverage:critical:compare
--base temp/base-critical-coverage/critical-unit-coverage.json
--head temp/head-critical-coverage/critical-unit-coverage.json

View File

@@ -54,11 +54,6 @@ const config: KnipConfig = {
'.github/workflows/ci-oss-assets-validation.yaml',
// Pending integration in stacked PR
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
// Pending integration in the workspace-settings stacked PRs: consumed by
// split/auto-reload + split/allowlist (Switch) and split/member-auditing
// + split/allowlist (Pagination); each consumer removes its entry
'src/components/ui/switch/Switch.vue',
'src/components/ui/pagination/Pagination.vue',
// Marketing media tooling — adopted by pages in a follow-up PR
'apps/website/src/components/common/SiteVideo.vue',
'apps/website/src/utils/marketingImage.ts',

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.48.0",
"version": "1.48.1",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -19,6 +19,8 @@
"size:collect": "node scripts/size-collect.js",
"size:report": "node scripts/size-report.js",
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
"coverage:critical:compare": "tsx scripts/critical-coverage/compareCriticalCoverage.ts",
"coverage:critical:extract": "tsx scripts/critical-coverage/extractCriticalCoverage.ts",
"dev:cloud": "pnpm dev:cloud:test",
"dev:cloud:test": "cross-env DEV_SERVER_COMFYUI_URL=https://testcloud.comfy.org/ vite --config vite.config.mts",
"dev:cloud:staging": "cross-env DEV_SERVER_COMFYUI_URL=https://stagingcloud.comfy.org/ vite --config vite.config.mts",

View File

@@ -0,0 +1,120 @@
import { appendFileSync } from 'node:fs'
import {
compareCriticalCoverageReports,
readCriticalCoverageReport
} from './criticalCoverageReport'
import type { CriticalCoverageComparison } from './criticalCoverageReport'
interface Options {
base: string
head: string
}
const options = parseOptions(process.argv.slice(2))
const base = readCriticalCoverageReport(options.base)
const head = readCriticalCoverageReport(options.head)
const comparison = compareCriticalCoverageReports(base, head)
const summary = formatComparison(comparison)
process.stdout.write(`${summary}\n`)
if (process.env.GITHUB_STEP_SUMMARY) {
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`)
}
if (comparison.commonBranches === 0) {
process.stderr.write('No comparable critical unit branches found.\n')
process.exit(1)
}
if (comparison.coveredBranchDelta < 0) {
process.stderr.write(
`Critical unit coverage dropped by ${Math.abs(comparison.coveredBranchDelta)} covered branches on the shared branch set.\n`
)
process.exit(1)
}
function parseOptions(args: string[]): Options {
const options: Partial<Options> = {}
for (let i = 0; i < args.length; i++) {
const arg = args[i]
const next = args[i + 1]
if (arg === '--base' && next) {
options.base = next
i++
} else if (arg.startsWith('--base=')) {
options.base = arg.slice('--base='.length)
} else if (arg === '--head' && next) {
options.head = next
i++
} else if (arg.startsWith('--head=')) {
options.head = arg.slice('--head='.length)
}
}
if (!options.base || !options.head) {
throw new Error(
'Usage: compareCriticalCoverage --base <json> --head <json>'
)
}
return {
base: options.base,
head: options.head
}
}
function formatComparison(comparison: CriticalCoverageComparison): string {
const passed = comparison.coveredBranchDelta >= 0
const lines = [
`## Critical Unit Coverage Gate: ${passed ? 'PASS' : 'FAIL'}`,
'',
`Base tested commit: \`${comparison.baseSha}\``,
`PR tested commit: \`${comparison.headSha}\``,
'',
'| Metric | Count |',
'|---|--:|',
`| Comparable critical branches | ${comparison.commonBranches} |`,
`| Covered in base | ${comparison.commonCoveredBranchesInBase} |`,
`| Covered in head | ${comparison.commonCoveredBranchesInHead} |`,
`| Covered branch delta | ${formatSignedCount(comparison.coveredBranchDelta)} |`,
`| Base-only branches | ${comparison.baseOnlyBranches} |`,
`| Head-only branches | ${comparison.headOnlyBranches} |`,
`| Covered-to-uncovered branches | ${comparison.regressions.length} |`,
''
]
if (passed) {
lines.push('PASS: Critical branch coverage did not decrease.')
return lines.join('\n')
}
lines.push('| File | Line | Branch | Base | Head |')
lines.push('|---|--:|---|--:|--:|')
for (const regression of comparison.regressions.slice(0, 25)) {
lines.push(
`| \`${regression.file}\` | ${regression.line} | ${regression.block}:${regression.branch} | ${formatTaken(regression.baseTaken)} | ${formatTaken(regression.headTaken)} |`
)
}
if (comparison.regressions.length > 25) {
lines.push('')
lines.push(
`${comparison.regressions.length - 25} additional regressions omitted from this summary.`
)
}
return lines.join('\n')
}
function formatTaken(value: number | null): string {
return value === null ? '0' : String(value)
}
function formatSignedCount(value: number): string {
return value > 0 ? `+${value}` : String(value)
}

View File

@@ -0,0 +1,45 @@
export const CRITICAL_COVERAGE_DIRS = [
'src/base',
'src/composables',
'src/core',
'src/lib/litegraph/src/node',
'src/lib/litegraph/src/subgraph',
'src/lib/litegraph/src/utils',
'src/platform/assets/composables',
'src/platform/assets/mappings',
'src/platform/assets/schemas',
'src/platform/assets/services',
'src/platform/assets/utils',
'src/platform/errorCatalog',
'src/platform/keybindings',
'src/platform/missingMedia',
'src/platform/missingModel',
'src/platform/navigation',
'src/platform/nodeReplacement',
'src/platform/remote',
'src/platform/remoteConfig',
'src/platform/secrets',
'src/platform/settings',
'src/platform/workflow',
'src/platform/workspace/api',
'src/platform/workspace/auth',
'src/platform/workspace/composables',
'src/platform/workspace/stores',
'src/platform/workspace/utils',
'src/schemas',
'src/scripts',
'src/services',
'src/stores',
'src/utils',
'src/workbench/extensions/manager/composables',
'src/workbench/extensions/manager/services',
'src/workbench/extensions/manager/stores',
'src/workbench/extensions/manager/utils',
'src/workbench/utils'
] as const
export function isCriticalCoveragePath(filePath: string): boolean {
return CRITICAL_COVERAGE_DIRS.some(
(dir) => filePath === dir || filePath.startsWith(`${dir}/`)
)
}

View File

@@ -0,0 +1,322 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, isAbsolute, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
CRITICAL_COVERAGE_DIRS,
isCriticalCoveragePath
} from './criticalCoverageDirs'
export interface CriticalBranchCoverage {
key: string
file: string
line: number
block: string
branch: string
taken: number | null
covered: boolean
}
export interface CriticalCoverageReport {
schemaVersion: 1
source: 'lcov'
sha: string
generatedAt: string
inputPath: string
criticalDirs: readonly string[]
totals: {
files: number
branches: number
coveredBranches: number
}
branches: CriticalBranchCoverage[]
}
export interface CriticalCoverageRegression extends CriticalBranchCoverage {
baseTaken: number | null
headTaken: number | null
}
export interface CriticalCoverageComparison {
baseSha: string
headSha: string
commonBranches: number
baseOnlyBranches: number
headOnlyBranches: number
commonCoveredBranchesInBase: number
commonCoveredBranchesInHead: number
coveredBranchDelta: number
regressions: CriticalCoverageRegression[]
}
interface CreateReportOptions {
inputPath: string
sha: string
generatedAt?: string
cwd?: string
}
export function createCriticalCoverageReport({
inputPath,
sha,
generatedAt = new Date().toISOString(),
cwd = process.cwd()
}: CreateReportOptions): CriticalCoverageReport {
const lcov = readFileSync(inputPath, 'utf-8')
const branches = parseCriticalBranches(lcov, cwd)
const files = new Set(branches.map((branch) => branch.file))
const coveredBranches = branches.filter((branch) => branch.covered).length
return {
schemaVersion: 1,
source: 'lcov',
sha,
generatedAt,
inputPath,
criticalDirs: CRITICAL_COVERAGE_DIRS,
totals: {
files: files.size,
branches: branches.length,
coveredBranches
},
branches
}
}
export function writeCriticalCoverageReport(
report: CriticalCoverageReport,
outputPath: string
): void {
mkdirSync(dirname(outputPath), { recursive: true })
writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`)
}
export function readCriticalCoverageReport(
inputPath: string
): CriticalCoverageReport {
const parsed: unknown = JSON.parse(readFileSync(inputPath, 'utf-8'))
if (!isCriticalCoverageReport(parsed)) {
throw new Error(`Invalid critical coverage report: ${inputPath}`)
}
return parsed
}
export function compareCriticalCoverageReports(
base: CriticalCoverageReport,
head: CriticalCoverageReport
): CriticalCoverageComparison {
const baseBranches = new Map(
base.branches.map((branch) => [branch.key, branch])
)
const headBranches = new Map(
head.branches.map((branch) => [branch.key, branch])
)
const regressions: CriticalCoverageRegression[] = []
let commonBranches = 0
let commonCoveredBranchesInBase = 0
let commonCoveredBranchesInHead = 0
let baseOnlyBranches = 0
for (const [key, baseBranch] of baseBranches) {
const headBranch = headBranches.get(key)
if (!headBranch) {
baseOnlyBranches++
continue
}
commonBranches++
if (baseBranch.covered) {
commonCoveredBranchesInBase++
}
if (headBranch.covered) {
commonCoveredBranchesInHead++
}
if (baseBranch.covered && !headBranch.covered) {
regressions.push({
...baseBranch,
baseTaken: baseBranch.taken,
headTaken: headBranch.taken
})
}
}
return {
baseSha: base.sha,
headSha: head.sha,
commonBranches,
baseOnlyBranches,
headOnlyBranches: [...headBranches.keys()].filter(
(key) => !baseBranches.has(key)
).length,
commonCoveredBranchesInBase,
commonCoveredBranchesInHead,
coveredBranchDelta:
commonCoveredBranchesInHead - commonCoveredBranchesInBase,
regressions: regressions.sort(compareBranches)
}
}
function parseCriticalBranches(
lcov: string,
cwd: string
): CriticalBranchCoverage[] {
let currentFile = ''
const branches = new Map<string, CriticalBranchCoverage>()
for (const line of lcov.split('\n')) {
if (line.startsWith('SF:')) {
currentFile = normalizeCoveragePath(line.slice(3), cwd)
continue
}
if (!line.startsWith('BRDA:') || !isCriticalCoveragePath(currentFile)) {
continue
}
const branch = parseBranchData(currentFile, line.slice(5))
if (!branch) {
continue
}
const existing = branches.get(branch.key)
if (!existing) {
branches.set(branch.key, branch)
continue
}
branches.set(branch.key, mergeBranchCoverage(existing, branch))
}
return [...branches.values()].sort(compareBranches)
}
function parseBranchData(
file: string,
data: string
): CriticalBranchCoverage | null {
const [lineValue, block, branch, takenValue] = data.split(',')
const line = Number(lineValue)
if (
!Number.isInteger(line) ||
!block ||
!branch ||
takenValue === undefined
) {
return null
}
const taken = takenValue === '-' ? null : Number(takenValue)
const covered = taken !== null && Number.isFinite(taken) && taken > 0
const key = `${file}:${line}:${block}:${branch}`
return {
key,
file,
line,
block,
branch,
taken: Number.isFinite(taken) ? taken : null,
covered
}
}
function mergeBranchCoverage(
left: CriticalBranchCoverage,
right: CriticalBranchCoverage
): CriticalBranchCoverage {
const taken =
left.taken === null || right.taken === null
? null
: left.taken + right.taken
return {
...left,
taken,
covered: left.covered || right.covered
}
}
function compareBranches(
left: CriticalBranchCoverage,
right: CriticalBranchCoverage
): number {
return (
left.file.localeCompare(right.file) ||
left.line - right.line ||
left.block.localeCompare(right.block) ||
left.branch.localeCompare(right.branch)
)
}
function normalizeCoveragePath(filePath: string, cwd: string): string {
const decodedPath = filePath.startsWith('file://')
? fileURLToPath(filePath)
: filePath
const relativePath = isAbsolute(decodedPath)
? relative(cwd, decodedPath)
: decodedPath
const normalizedPath = relativePath.replace(/\\/g, '/').replace(/^\.\//, '')
if (!normalizedPath.startsWith('../')) {
return normalizedPath
}
const srcIndex = normalizedPath.indexOf('/src/')
return srcIndex === -1 ? normalizedPath : normalizedPath.slice(srcIndex + 1)
}
function isCriticalCoverageReport(
value: unknown
): value is CriticalCoverageReport {
if (!isRecord(value)) {
return false
}
const totals = value.totals
return (
value.schemaVersion === 1 &&
value.source === 'lcov' &&
typeof value.sha === 'string' &&
typeof value.generatedAt === 'string' &&
typeof value.inputPath === 'string' &&
Array.isArray(value.criticalDirs) &&
isRecord(totals) &&
typeof totals.files === 'number' &&
typeof totals.branches === 'number' &&
typeof totals.coveredBranches === 'number' &&
Array.isArray(value.branches) &&
value.branches.every(isCriticalBranchCoverage)
)
}
function isCriticalBranchCoverage(
value: unknown
): value is CriticalBranchCoverage {
if (!isRecord(value)) {
return false
}
return (
typeof value.key === 'string' &&
typeof value.file === 'string' &&
typeof value.line === 'number' &&
typeof value.block === 'string' &&
typeof value.branch === 'string' &&
(typeof value.taken === 'number' || value.taken === null) &&
typeof value.covered === 'boolean'
)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}

View File

@@ -0,0 +1,58 @@
import {
createCriticalCoverageReport,
writeCriticalCoverageReport
} from './criticalCoverageReport'
interface Options {
input: string
output: string
sha: string
}
const options = parseOptions(process.argv.slice(2))
const report = createCriticalCoverageReport({
inputPath: options.input,
sha: options.sha
})
writeCriticalCoverageReport(report, options.output)
process.stdout.write(
[
`Critical coverage branches: ${report.totals.coveredBranches}/${report.totals.branches}`,
`Critical coverage files: ${report.totals.files}`,
`Wrote ${options.output}`
].join('\n') + '\n'
)
function parseOptions(args: string[]): Options {
const options: Options = {
input: 'coverage/lcov.info',
output: 'coverage/critical-unit-coverage.json',
sha: process.env.GITHUB_SHA ?? 'unknown'
}
for (let i = 0; i < args.length; i++) {
const arg = args[i]
const next = args[i + 1]
if (arg === '--input' && next) {
options.input = next
i++
} else if (arg.startsWith('--input=')) {
options.input = arg.slice('--input='.length)
} else if (arg === '--output' && next) {
options.output = next
i++
} else if (arg.startsWith('--output=')) {
options.output = arg.slice('--output='.length)
} else if (arg === '--sha' && next) {
options.sha = next
i++
} else if (arg.startsWith('--sha=')) {
options.sha = arg.slice('--sha='.length)
}
}
return options
}

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import { ZIndex } from '@primeuix/utils/zindex'
import type { MenuItem } from 'primevue/menuitem'
import {
DropdownMenuArrow,
@@ -12,21 +11,15 @@ 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,
modal = true
} = defineProps<{
const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
entries?: MenuItem[]
icon?: string
to?: string | HTMLElement
@@ -34,7 +27,6 @@ const {
contentClass?: string
buttonSize?: ButtonVariants['size']
buttonClass?: string
modal?: boolean
}>()
const itemClass = computed(() =>
@@ -51,19 +43,12 @@ 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 = computed(() => {
if (!open.value) return undefined
const topZIndex = ZIndex.getCurrent('modal')
return topZIndex >= MODAL_BASE_Z_INDEX ? { zIndex: topZIndex + 1 } : undefined
})
const contentStyle = useModalLiftedZIndex(open)
</script>
<template>
<DropdownMenuRoot v-model:open="open" :modal>
<DropdownMenuRoot v-model:open="open">
<DropdownMenuTrigger as-child>
<slot name="button">
<Button :size="buttonSize ?? 'icon'" :class="buttonClass">

View File

@@ -1,40 +0,0 @@
<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

@@ -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"], [aria-haspopup="menu"], [aria-haspopup="dialog"], [aria-haspopup="listbox"]'
'[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"]'
const OUTSIDE_LAYER_SELECTORS = `${PRIMEVUE_OVERLAY_SELECTORS}, ${REKA_PORTAL_SELECTORS}`

View File

@@ -1,72 +0,0 @@
<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-sm',
inputText: 'text-xs',
clearPos: 'left-2.5'
},
xl: {

View File

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

@@ -1,17 +0,0 @@
<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

@@ -1,13 +0,0 @@
<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

@@ -1,13 +0,0 @@
<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

@@ -1,21 +0,0 @@
<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

@@ -1,15 +0,0 @@
<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

@@ -1,20 +0,0 @@
<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

@@ -14,12 +14,7 @@
>
<header
data-component-id="LeftPanelHeader"
:class="
cn(
'flex h-18 w-full shrink-0 items-center-safe gap-2 pr-3 pl-6',
headerHeightClass
)
"
class="flex h-18 w-full shrink-0 items-center-safe gap-2 pr-3 pl-6"
>
<slot name="leftPanelHeaderTitle" />
<Button
@@ -38,12 +33,7 @@
<div class="flex flex-col overflow-hidden bg-base-background">
<header
v-if="$slots.header"
:class="
cn(
'flex h-18 w-full items-center justify-between gap-2 px-6',
headerHeightClass
)
"
class="flex h-18 w-full items-center justify-between gap-2 px-6"
>
<div class="flex min-w-0 flex-1 gap-2">
<Button
@@ -161,22 +151,20 @@ const SIZE_CLASSES = {
} as const
type ModalSize = keyof typeof SIZE_CLASSES
type ContentPadding = 'default' | 'compact' | 'none' | 'flush'
type ContentPadding = 'default' | 'compact' | 'none'
const {
contentTitle,
rightPanelTitle,
size = 'lg',
leftPanelWidth = '14rem',
contentPadding = 'default',
headerHeightClass = 'h-18'
contentPadding = 'default'
} = defineProps<{
contentTitle: string
rightPanelTitle?: string
size?: ModalSize
leftPanelWidth?: string
contentPadding?: ContentPadding
headerHeightClass?: string
}>()
const sizeClasses = computed(() => SIZE_CLASSES[size])
@@ -216,10 +204,7 @@ 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',
// 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'
contentPadding === 'compact' && 'px-6 pt-0 pb-2'
)
)

View File

@@ -90,9 +90,7 @@ 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/',
teamPlanRequests:
'https://comfy-org.portal.usepylon.com/forms/team-plan-requests'
comfyOrg: 'https://www.comfy.org/'
}
/** Common doc paths for use with buildDocsUrl */

View File

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

View File

@@ -2414,6 +2414,16 @@
"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",
@@ -2846,7 +2856,7 @@
"workspacePanel": {
"invite": "Invite",
"inviteMember": "Invite member",
"inviteLimitReached": "Your workspace is at the member limit",
"inviteLimitReached": "You've reached the maximum of {count} members",
"tabs": {
"dashboard": "Dashboard",
"planCredits": "Plan & Credits",
@@ -2861,17 +2871,12 @@
"pendingInvitesCount": "{count} pending invite | {count} pending invites",
"tabs": {
"active": "Active",
"pendingCount": "Pending ({count})",
"membersCount": "Members ({count})",
"pending": "Pending"
"pendingCount": "Pending ({count})"
},
"columns": {
"inviteDate": "Invite date",
"expiryDate": "Expiry date",
"role": "Role",
"creditsUsed": "Credits used this month",
"email": "Email",
"lastActivity": "Last activity"
"role": "Role"
},
"actions": {
"resendInvite": "Resend invite",
@@ -2887,22 +2892,14 @@
"contactUs": "Contact us",
"noInvites": "No pending invites",
"noMembers": "No members",
"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."
"searchPlaceholder": "Search..."
},
"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",
"renameWorkspace": "Rename Workspace"
"creatorCannotLeave": "The workspace creator can't leave the workspace they created"
},
"editWorkspaceDialog": {
"title": "Edit workspace details",
@@ -2991,18 +2988,6 @@
"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"
}
},
"teamWorkspacesDialog": {
@@ -3015,7 +3000,7 @@
"newWorkspace": "New workspace",
"namePlaceholder": "e.g. Marketing Team",
"createWorkspace": "Create workspace",
"nameValidationError": "Name must be 130 characters using letters, numbers, spaces, or common punctuation."
"nameValidationError": "Name must be 150 characters using letters, numbers, spaces, or common punctuation."
},
"workspaceSwitcher": {
"switchWorkspace": "Switch workspace",
@@ -3025,8 +3010,7 @@
"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",
"roleAdmin": "Admin"
"failedToSwitch": "Failed to switch workspace"
},
"selectionToolbox": {
"executeButton": {

View File

@@ -515,57 +515,52 @@
},
"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": {
"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)"
"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"
},
"intent": {
"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",
"apps_api": "Aplicaciones y APIs",
"exploring": "Solo explorando",
"images": "Imágenes",
"not_sure": "No estoy seguro",
"videos": "Videos",
"other": "Otra cosa",
"otherPlaceholder": "¿Qué quieres crear?",
"video": "Video",
"workflows": "Flujos de trabajo o pipelines personalizados"
},
"source": {
"conference": "Conferencia o evento",
"discord": "Discord / comunidad",
"community": "Una comunidad o foro",
"friend": "Amigo o colega",
"github": "GitHub",
"other": "Otro",
"otherPlaceholder": "¿Dónde nos encontraste?",
"search": "Google / búsqueda",
"social": "Redes sociales"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "Newsletter o blog",
"other": "Otro",
"reddit": "Reddit",
"search": "Google / búsqueda",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "X (Twitter)",
"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"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "Conoce más sobre Cloud",
"cloudStart_title": "comienza a crear en segundos",
"cloudStart_wantToRun": "¿Prefieres ejecutar ComfyUI localmente?",
"cloudSurvey_steps_familiarity": "¿Qué tan familiarizado estás con ComfyUI?",
"cloudSurvey_steps_experience": "¿Qué tanto conoces ComfyUI?",
"cloudSurvey_steps_focus": "¿Qué estás construyendo?",
"cloudSurvey_steps_intent": "¿Qué quieres crear con ComfyUI?",
"cloudSurvey_steps_source": "¿Dónde escuchaste sobre ComfyUI?",
"cloudSurvey_steps_usage": "¿Cómo planeas usar ComfyUI?",
"cloudSurvey_steps_source_social": "¿En qué plataforma?",
"cloudWaitlist_contactLink": "aquí",
"cloudWaitlist_questionsText": "¿Preguntas? Contáctanos",
"color": {

View File

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

View File

@@ -515,57 +515,52 @@
},
"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": {
"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)"
"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"
},
"intent": {
"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",
"apps_api": "Applications et API",
"exploring": "Je découvre simplement",
"images": "Images",
"not_sure": "Pas sûr",
"videos": "Vidéos",
"other": "Autre chose",
"otherPlaceholder": "Qu'aimeriez-vous créer ?",
"video": "Vidéo",
"workflows": "Workflows ou pipelines personnalisés"
},
"source": {
"conference": "Conférence ou événement",
"discord": "Discord / communauté",
"community": "Une communauté ou un forum",
"friend": "Ami ou collègue",
"github": "GitHub",
"other": "Autre",
"otherPlaceholder": "Où nous avez-vous trouvés ?",
"search": "Google / recherche",
"social": "Réseaux sociaux"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "Newsletter ou blog",
"other": "Autre",
"reddit": "Reddit",
"search": "Google / recherche",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "X (Twitter)",
"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"
}
},
@@ -578,10 +573,11 @@
"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_familiarity": "Quelle est votre familiarité avec ComfyUI ?",
"cloudSurvey_steps_experience": "Quel est votre niveau de connaissance de ComfyUI ?",
"cloudSurvey_steps_focus": "Qu'êtes-vous en train de créer ?",
"cloudSurvey_steps_intent": "Que souhaitez-vous créer avec ComfyUI ?",
"cloudSurvey_steps_source": "Où avez-vous entendu parler de ComfyUI ?",
"cloudSurvey_steps_usage": "Comment prévoyez-vous d'utiliser ComfyUI ?",
"cloudSurvey_steps_source_social": "Quelle plateforme ?",
"cloudWaitlist_contactLink": "ici",
"cloudWaitlist_questionsText": "Des questions ? Contactez-nous",
"color": {

View File

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

View File

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

View File

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

View File

@@ -515,57 +515,52 @@
},
"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": {
"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)"
"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"
},
"intent": {
"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",
"apps_api": "Apps e APIs",
"exploring": "Só explorando",
"images": "Imagens",
"not_sure": "Não tenho certeza",
"videos": "Vídeos",
"other": "Outra coisa",
"otherPlaceholder": "O que você quer criar?",
"video": "Vídeo",
"workflows": "Workflows ou pipelines personalizados"
},
"source": {
"conference": "Conferência ou evento",
"discord": "Discord / comunidade",
"community": "Uma comunidade ou fórum",
"friend": "Amigo ou colega",
"github": "GitHub",
"other": "Outro",
"otherPlaceholder": "Onde você nos encontrou?",
"search": "Google / busca",
"social": "Mídias sociais"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "Newsletter ou blog",
"other": "Outro",
"reddit": "Reddit",
"search": "Google / busca",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "X (Twitter)",
"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"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "Saiba mais sobre a Nuvem",
"cloudStart_title": "comece a criar em segundos",
"cloudStart_wantToRun": "Prefere rodar o ComfyUI localmente?",
"cloudSurvey_steps_familiarity": "Qual o seu nível de familiaridade com o ComfyUI?",
"cloudSurvey_steps_experience": "Qual o seu nível de conhecimento do ComfyUI?",
"cloudSurvey_steps_focus": "O que você está construindo?",
"cloudSurvey_steps_intent": "O que você deseja criar com o ComfyUI?",
"cloudSurvey_steps_source": "Onde você ouviu falar do ComfyUI?",
"cloudSurvey_steps_usage": "Como você pretende usar o ComfyUI?",
"cloudSurvey_steps_source_social": "Em qual plataforma?",
"cloudWaitlist_contactLink": "aqui",
"cloudWaitlist_questionsText": "Dúvidas? Entre em contato conosco",
"color": {

View File

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

View File

@@ -515,57 +515,52 @@
},
"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": {
"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)"
"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"
},
"intent": {
"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",
"apps_api": "Uygulamalar ve API'ler",
"exploring": "Sadece keşfediyorum",
"images": "Görseller",
"not_sure": "Emin değilim",
"videos": "Videolar",
"other": "Başka bir şey",
"otherPlaceholder": "Ne yapmak istiyorsunuz?",
"video": "Video",
"workflows": "Özel iş akışları veya boru hatları"
},
"source": {
"conference": "Konferans veya etkinlik",
"discord": "Discord / topluluk",
"community": "Bir topluluk veya forum",
"friend": "Arkadaş veya iş arkadaşı",
"github": "GitHub",
"other": "Diğer",
"otherPlaceholder": "Bizi nereden buldunuz?",
"search": "Google / arama",
"social": "Sosyal medya"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "Bülten veya blog",
"other": "Diğer",
"reddit": "Reddit",
"search": "Google / arama",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "X (Twitter)",
"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"
}
},
@@ -578,10 +573,11 @@
"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_familiarity": "ComfyUI'ya ne kadar aşinasınız?",
"cloudSurvey_steps_experience": "ComfyUI'yi ne kadar iyi biliyorsunuz?",
"cloudSurvey_steps_focus": "Ne inşa ediyorsunuz?",
"cloudSurvey_steps_intent": "ComfyUI ile ne oluşturmak istiyorsunuz?",
"cloudSurvey_steps_source": "ComfyUI'yi nereden duydunuz?",
"cloudSurvey_steps_usage": "ComfyUI'yi nasıl kullanmayı planlıyorsunuz?",
"cloudSurvey_steps_source_social": "Hangi platform?",
"cloudWaitlist_contactLink": "burada",
"cloudWaitlist_questionsText": "Sorularınız mı var? Bize ulaşın",
"color": {

View File

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

View File

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

View File

@@ -1,48 +1,67 @@
<template>
<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')"
<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"
>
<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'),
value: $t('mediaAsset.selection.deselectAll'),
showDelay: 300
}"
variant="inverted"
size="icon-lg"
type="button"
data-testid="assets-delete-selected"
:aria-label="$t('mediaAsset.selection.deleteSelected')"
data-testid="assets-deselect-selected"
:aria-label="$t('mediaAsset.selection.deselectAll')"
class="rounded-lg hover:bg-base-background/10"
@click="emit('delete')"
@click="emit('deselect')"
>
<i class="icon-[lucide--trash-2] size-4" />
<i class="icon-[lucide--x] size-4" />
</Button>
</template>
</SelectionBar>
<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>
<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,7 +1,5 @@
<template>
<div
class="dark-theme flex max-h-[85vh] w-full max-w-md flex-col overflow-y-auto px-4 sm:px-6"
>
<div class="dark-theme flex max-h-full w-full max-w-md flex-col 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

@@ -0,0 +1,618 @@
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

@@ -0,0 +1,263 @@
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="overflow-hidden transition-[height] duration-300 ease-out"
class="max-h-[45vh] overflow-y-auto transition-[height] duration-300 ease-out sm:max-h-[55vh]"
:style="animatedHeightStyle"
>
<div ref="questionContent" class="relative">

View File

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

View File

@@ -1,11 +1,5 @@
<template>
<BaseModalLayout
content-title=""
data-testid="settings-dialog"
size="full"
header-height-class="h-22"
:content-padding="isWorkspacePanel ? 'flush' : 'default'"
>
<BaseModalLayout content-title="" data-testid="settings-dialog" size="full">
<template #leftPanelHeaderTitle>
<i class="icon-[lucide--settings]" />
<h2 class="text-neutral text-base">{{ $t('g.settings') }}</h2>
@@ -54,7 +48,6 @@
id="keybinding-panel-header"
class="flex-1"
/>
<WorkspaceSettingsHeader v-else-if="isWorkspacePanel" />
</template>
<template #header-right-area>
@@ -62,7 +55,6 @@
v-if="activeCategoryKey === 'keybinding'"
id="keybinding-panel-actions"
/>
<WorkspaceMenuButton v-else-if="isWorkspacePanel" />
</template>
<template #content>
@@ -101,11 +93,8 @@ 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 {
@@ -146,14 +135,6 @@ 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) => ({
@@ -191,17 +172,6 @@ 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

@@ -188,18 +188,6 @@ export function useSettingUI(
)
}
const membersPanel: SettingPanelItem = {
node: {
key: 'workspace-members',
label: 'Members',
children: []
},
component: defineAsyncComponent(
() =>
import('@/platform/workspace/components/dialogs/settings/WorkspaceMembersPanelContent.vue')
)
}
const shouldShowWorkspacePanel = computed(
() => teamWorkspacesEnabled.value && isLoggedIn.value
)
@@ -257,7 +245,7 @@ export function useSettingUI(
aboutPanel,
creditsPanel,
userPanel,
...(shouldShowWorkspacePanel.value ? [workspacePanel, membersPanel] : []),
...(shouldShowWorkspacePanel.value ? [workspacePanel] : []),
keybindingPanel,
extensionPanel,
...(isDesktop ? [serverConfigPanel] : []),
@@ -307,9 +295,7 @@ export function useSettingUI(
key: 'workspace',
label: 'Workspace',
children: [
...(shouldShowWorkspacePanel.value
? [workspacePanel.node, membersPanel.node]
: []),
...(shouldShowWorkspacePanel.value ? [workspacePanel.node] : []),
...(isLoggedIn.value &&
!(isCloud && window.__CONFIG__?.subscription_required)
? [creditsPanel.node]

View File

@@ -1,13 +0,0 @@
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,5 +87,3 @@ export type SettingPanelType =
| 'subscription'
| 'user'
| 'workspace'
| 'workspace-members'
| 'workspace-partner-nodes'

View File

@@ -37,11 +37,6 @@ 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 {

View File

@@ -1,37 +1,22 @@
<template>
<div
: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 }"
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)'
}"
>
<img
v-if="imageUrl"
:src="imageUrl"
:alt="workspaceName"
class="size-full object-cover"
/>
<template v-else>{{ letter }}</template>
{{ letter }}
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
defineOptions({ inheritAttrs: false })
const { workspaceName, imageUrl } = defineProps<{
const { workspaceName } = 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

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

View File

@@ -64,7 +64,6 @@ 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<{
@@ -81,11 +80,7 @@ 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 <= WORKSPACE_NAME_MAX_LENGTH &&
safeNameRegex.test(name)
)
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
})
function onCancel() {

View File

@@ -58,7 +58,6 @@ 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()
@@ -71,11 +70,7 @@ 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 <= WORKSPACE_NAME_MAX_LENGTH &&
safeNameRegex.test(name)
)
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
})
function onCancel() {

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex w-132 max-w-full flex-col rounded-2xl border border-border-default bg-base-background"
class="flex w-full max-w-lg 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 bg-tertiary-background-hover',
'rounded-full',
!EMAIL_REGEX.test(email) && 'bg-danger/20 text-danger'
)
"

View File

@@ -1,54 +0,0 @@
<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 the character limit', async () => {
it('disables create button for name exceeding 50 characters', async () => {
const { container, user } = mountComponent()
const input = container.querySelector(
'#workspace-name-input'

View File

@@ -145,7 +145,6 @@ 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<{
@@ -179,11 +178,7 @@ const tierLabels = computed(
const isValidName = computed(() => {
const name = workspaceName.value.trim()
return (
name.length >= 1 &&
name.length <= WORKSPACE_NAME_MAX_LENGTH &&
SAFE_NAME_REGEX.test(name)
)
return name.length >= 1 && name.length <= 50 && SAFE_NAME_REGEX.test(name)
})
function onCancel() {

View File

@@ -0,0 +1,91 @@
<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

@@ -1,117 +0,0 @@
<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,7 +1,8 @@
import { render, screen, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import type { Slots } from 'vue'
import { computed, h, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import MembersPanelContent from './MembersPanelContent.vue'
@@ -17,7 +18,6 @@ const mockMemberMenuItems = vi.fn(() => [])
const mockShowTeamPlans = vi.fn()
const mockToggleSort = vi.fn()
const mockHandleInviteMember = vi.fn()
const mockFetchBalance = vi.fn()
const {
mockMembers,
@@ -27,14 +27,13 @@ const {
mockFilteredPendingInvites,
mockIsPersonalWorkspace,
mockIsOnTeamPlan,
mockHasMultipleMembers,
mockShowSearch,
mockShowViewTabs,
mockShowInviteButton,
mockIsInviteDisabled,
mockActiveView,
mockSearchQuery,
mockSortField,
mockSortDirection,
mockPermissions,
mockUiConfig
} = vi.hoisted(() => {
@@ -45,6 +44,7 @@ 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,36 +55,39 @@ 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
canManageMembers: true,
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: true,
canTopUp: true
}),
mockUiConfig: ref({
showMembersList: true,
showPendingTab: true,
showSearch: 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
})
}
})
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({ isPaused: computed(() => false) })
}))
vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
useMembersPanel: () => ({
searchQuery: mockSearchQuery,
activeView: mockActiveView,
sortField: mockSortField,
sortDirection: mockSortDirection,
maxSeats: computed(() => 50),
memberCount: computed(() => mockMembers.value.length),
maxSeats: computed(() => 20),
isOnTeamPlan: mockIsOnTeamPlan,
hasLapsedTeamPlan: computed(() => false),
hasMultipleMembers: mockHasMultipleMembers,
showSearch: mockShowSearch,
showViewTabs: mockShowViewTabs,
showInviteButton: mockShowInviteButton,
@@ -101,6 +104,7 @@ vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
})),
filteredMembers: mockFilteredMembers,
filteredPendingInvites: mockFilteredPendingInvites,
memberMenuItems: mockMemberMenuItems,
memberMenus: computed(
() =>
new Map(
@@ -108,21 +112,28 @@ 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
handleRevokeInvite: mockHandleRevokeInvite,
handleRemoveMember: vi.fn(),
handleChangeRole: vi.fn()
})
}))
vi.mock('@/components/button/MoreButton.vue', () => ({
default: (_: unknown, { slots }: { slots: Slots }) =>
h('div', slots.default?.({ close: () => {} }))
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -146,15 +157,6 @@ 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: {
@@ -162,9 +164,8 @@ function renderComponent() {
stubs: {
Button: ButtonStub,
SearchInput: SearchInputStub,
DropdownMenu: DropdownMenuStub,
UserAvatar: true,
BillingStatusBanner: true
WorkspaceMenuButton: true
},
directives: { tooltip: () => {} }
}
@@ -206,6 +207,7 @@ describe('MembersPanelContent', () => {
mockFilteredPendingInvites.value = []
mockIsPersonalWorkspace.value = false
mockIsOnTeamPlan.value = true
mockHasMultipleMembers.value = true
mockShowSearch.value = true
mockShowViewTabs.value = true
mockShowInviteButton.value = true
@@ -213,15 +215,27 @@ describe('MembersPanelContent', () => {
mockActiveView.value = 'active'
mockSearchQuery.value = ''
mockPermissions.value = {
canViewOtherMembers: true,
canViewPendingInvites: true,
canInviteMembers: true,
canManageInvites: true,
canManageMembers: true
canManageMembers: true,
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: true,
canTopUp: true
}
mockUiConfig.value = {
showMembersList: true,
showPendingTab: true,
showSearch: 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
}
})
@@ -229,9 +243,13 @@ 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', () => {
@@ -257,7 +275,7 @@ describe('MembersPanelContent', () => {
})
})
describe('team workspace - member table', () => {
describe('team workspace - member list', () => {
it('shows the Role column header and member roles', () => {
mockFilteredMembers.value = [
createMember({ role: 'owner', email: 'boss@test.com' }),
@@ -267,47 +285,18 @@ describe('MembersPanelContent', () => {
expect(
screen.getByText('workspacePanel.members.columns.role')
).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()
expect(screen.getByText('workspaceSwitcher.roleMember')).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()
@@ -399,20 +388,34 @@ describe('MembersPanelContent', () => {
describe('member role', () => {
beforeEach(() => {
mockPermissions.value = {
canViewPendingInvites: true,
canViewOtherMembers: true,
canViewPendingInvites: false,
canInviteMembers: false,
canManageInvites: false,
canManageMembers: false
canManageMembers: false,
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: false,
canTopUp: false
}
mockUiConfig.value.showPendingTab = true
mockUiConfig.value.showPendingTab = false
})
it('shows the pending tab button (view-only)', () => {
it('hides the pending tab button', () => {
mockPendingInvites.value = [createInvite()]
renderComponent()
expect(
screen.getByText(/workspacePanel\.members\.tabs\.pendingCount/)
).toBeTruthy()
screen.queryByText(/workspacePanel\.members\.tabs\.pendingCount/)
).toBeNull()
})
it('does not show the pending invites header', () => {
mockActiveView.value = 'pending'
mockPendingInvites.value = [createInvite()]
renderComponent()
expect(
screen.queryByText(/workspacePanel\.members\.pendingInvitesCount/)
).toBeNull()
})
it('shows no action menus on member rows', () => {
@@ -448,6 +451,15 @@ 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()
@@ -460,15 +472,17 @@ describe('MembersPanelContent', () => {
})
describe('contact us footer', () => {
it('opens the team-plan request form in a new tab for team workspaces on a team plan', async () => {
it('opens discord in a new tab for team workspaces on a team plan', async () => {
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
renderComponent()
expect(screen.getByText(/needMoreMembers/)).toBeTruthy()
expect(
screen.getByText('workspacePanel.members.needMoreMembers')
).toBeTruthy()
await userEvent.click(
screen.getByText('workspacePanel.members.contactUs')
)
expect(openSpy).toHaveBeenCalledWith(
'https://comfy-org.portal.usepylon.com/forms/team-plan-requests',
'https://www.comfy.org/discord',
'_blank',
'noopener,noreferrer'
)
@@ -482,12 +496,16 @@ describe('MembersPanelContent', () => {
})
})
describe('member count tab', () => {
it('shows the members count tab for team workspace', () => {
mockMembers.value = [createMember({ id: '1' }), createMember({ id: '2' })]
describe('member count display', () => {
it('shows member count header for team workspace', () => {
mockFilteredMembers.value = [
createMember({ id: '1' }),
createMember({ id: '2' })
]
mockMembers.value = mockFilteredMembers.value
renderComponent()
expect(
screen.getByText(/workspacePanel\.members\.tabs\.membersCount/)
screen.getByText(/workspacePanel\.members\.membersCount/)
).toBeTruthy()
})
})
@@ -522,7 +540,10 @@ describe('MembersPanelContent', () => {
mockShowViewTabs.value = false
renderComponent()
expect(
screen.queryByText(/workspacePanel\.members\.tabs\.pendingCount/)
screen.queryByText('workspacePanel.members.tabs.active')
).toBeNull()
expect(
screen.queryByText('workspacePanel.members.columns.role')
).toBeNull()
})
})

View File

@@ -1,215 +1,209 @@
<template>
<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="grow overflow-auto pt-6">
<div
class="flex w-full flex-col gap-3 @2xl:flex-row @2xl:items-center @2xl:gap-9"
class="border-inter flex size-full flex-col gap-2 rounded-2xl border border-interface-stroke p-6"
>
<div class="flex min-w-0 flex-1 items-center gap-2">
<template v-if="showViewTabs">
<Button
:variant="activeView === 'active' ? 'secondary' : 'muted-textonly'"
<!-- 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"
@click="activeView = 'active'"
>
{{ $t('workspacePanel.members.tabs.membersCount', memberCount) }}
</Button>
<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>
<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"
>
{{ $t('workspacePanel.invite') }}
<i class="icon-[lucide--plus] size-4" />
</Button>
</div>
</div>
<!-- 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')"
>
<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')"
>
<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')"
>
<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"
class="w-64"
/>
<template v-else>
<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>
</TableBody>
</Table>
<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"
<Button
v-if="showInviteButton"
v-tooltip="
inviteTooltip
? { value: inviteTooltip, showDelay: 0 }
: { value: $t('workspacePanel.inviteMember'), showDelay: 300 }
"
variant="secondary"
size="lg"
:disabled="isInviteDisabled"
:aria-label="$t('workspacePanel.inviteMember')"
@click="handleInviteMember"
>
<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"
{{ $t('workspacePanel.invite') }}
<i class="pi pi-plus text-sm" />
</Button>
<WorkspaceMenuButton v-if="permissions.canAccessWorkspaceMenu" />
</div>
</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
)
"
>
<!-- 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.members.tabs.active') }}
</Button>
<Button
v-if="uiConfig.showPendingTab"
:variant="
activeView === 'pending' ? 'secondary' : 'muted-textonly'
"
size="md"
@click="activeView = 'pending'"
>
{{
$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')"
>
{{ $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>
<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" />
</template>
</div>
<!-- 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"
@resend="handleResendInvite"
@revoke="handleRevokeInvite"
/>
<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>
</div>
</div>
<!-- Upsell Banner -->
<MemberUpsellBanner
v-if="!isOnTeamPlan"
:reactivate="hasLapsedTeamPlan"
@show-plans="showTeamPlans()"
/>
<!-- Need More Members Footer -->
<div
v-if="isOnTeamPlan && !isPersonalWorkspace"
class="flex h-8 items-center"
class="flex items-center pt-2"
>
<p class="text-sm text-muted-foreground">
{{ membersUsageLabel }}
<template v-if="permissions.canInviteMembers">
{{ $t('workspacePanel.members.needMoreMembers') }}
</template>
{{ $t('workspacePanel.members.needMoreMembers') }}
</p>
<Button
v-if="permissions.canInviteMembers"
variant="muted-textonly"
size="md"
class="text-sm text-base-foreground"
size="sm"
class="text-base-foreground"
@click="handleContactUs"
>
{{ $t('workspacePanel.members.contactUs') }}
@@ -221,30 +215,21 @@
<script setup lang="ts">
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Button from '@/components/ui/button/Button.vue'
import Table from '@/components/ui/table/Table.vue'
import TableBody from '@/components/ui/table/TableBody.vue'
import TableCell from '@/components/ui/table/TableCell.vue'
import TableHead from '@/components/ui/table/TableHead.vue'
import TableHeader from '@/components/ui/table/TableHeader.vue'
import TableRow from '@/components/ui/table/TableRow.vue'
import { useExternalLink } from '@/composables/useExternalLink'
import MemberTableRow from '@/platform/workspace/components/dialogs/settings/MemberTableRow.vue'
import MemberListItem from '@/platform/workspace/components/dialogs/settings/MemberListItem.vue'
import MemberUpsellBanner from '@/platform/workspace/components/dialogs/settings/MemberUpsellBanner.vue'
import PendingInviteRow from '@/platform/workspace/components/dialogs/settings/PendingInviteRow.vue'
import PendingInvitesList from '@/platform/workspace/components/dialogs/settings/PendingInvitesList.vue'
import WorkspaceMenuButton from '@/platform/workspace/components/dialogs/settings/WorkspaceMenuButton.vue'
import { useMembersPanel } from '@/platform/workspace/composables/useMembersPanel'
import { 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,
@@ -256,10 +241,11 @@ const {
filteredPendingInvites,
memberMenus,
isPersonalWorkspace,
members,
pendingInvites,
permissions,
uiConfig,
fetchBalance,
userPhotoUrl,
isCurrentUser,
isOriginalOwner,
toggleSort,
@@ -269,38 +255,8 @@ 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.teamPlanRequests, '_blank', 'noopener,noreferrer')
window.open(staticUrls.discord, '_blank', 'noopener,noreferrer')
}
onMounted(() => {
void fetchBalance()
})
</script>

View File

@@ -1,88 +0,0 @@
<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

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

View File

@@ -0,0 +1,112 @@
<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

@@ -1,21 +0,0 @@
<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,7 +5,6 @@ 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'
@@ -76,25 +75,6 @@ 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,16 +1,10 @@
<template>
<DropdownMenu
v-if="menuItems.length > 0"
:entries="menuItems"
:modal="false"
@close-auto-focus="onMenuCloseAutoFocus"
>
<DropdownMenu :entries="menuItems">
<template #button>
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="secondary"
variant="muted-textonly"
size="icon-lg"
class="rounded-lg"
:aria-label="$t('g.moreOptions')"
>
<i class="pi pi-ellipsis-h" />
@@ -27,35 +21,20 @@ 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 } =
useDialogService()
const {
showLeaveWorkspaceDialog,
showDeleteWorkspaceDialog,
showEditWorkspaceDialog
} = 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.
@@ -72,21 +51,22 @@ const deleteTooltip = computed(() => {
})
const menuItems = computed<MenuItem[]>(() => {
const renameItems: MenuItem[] = uiConfig.value.showEditWorkspaceMenuItem
? [
{
label: t('workspacePanel.menu.renameWorkspace'),
command: beginRename
}
]
: []
const items: MenuItem[] = []
if (uiConfig.value.showEditWorkspaceMenuItem) {
items.push({
label: t('workspacePanel.menu.editWorkspace'),
icon: 'pi pi-pencil',
command: () => showEditWorkspaceDialog()
})
}
const destructiveItems: MenuItem[] = []
const action = uiConfig.value.workspaceMenuAction
if (action === 'delete') {
destructiveItems.push({
items.push({
label: t('workspacePanel.menu.deleteWorkspace'),
class: isDeleteDisabled.value ? undefined : 'text-danger',
icon: 'pi pi-trash',
class: isDeleteDisabled.value ? 'text-danger/50' : 'text-danger',
disabled: isDeleteDisabled.value,
tooltip: deleteTooltip.value,
command: isDeleteDisabled.value
@@ -97,23 +77,23 @@ const menuItems = computed<MenuItem[]>(() => {
// Members and non-creator owners can leave; the creator sees it disabled.
if (action === 'leave' || action === 'delete') {
destructiveItems.push(
items.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()
}
)
}
const divider: MenuItem[] =
renameItems.length && destructiveItems.length ? [{ separator: true }] : []
return [...renameItems, ...divider, ...destructiveItems]
return items
})
</script>

View File

@@ -1,143 +0,0 @@
<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

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

View File

@@ -5,7 +5,6 @@ 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'
@@ -21,37 +20,15 @@ import {
import { useDialogService } from '@/services/dialogService'
type ActiveView = 'active' | 'pending'
type SortField =
| 'email'
| 'role'
| 'lastActivity'
| 'credits'
| 'inviteDate'
| 'expiryDate'
type SortField = 'inviteDate' | 'expiryDate' | 'role'
type SortDirection = 'asc' | 'desc'
export function sortMembers(
members: WorkspaceMember[],
currentUserEmail: string | null,
sortDirection: SortDirection,
originalOwnerId: string | null = null,
sortField: SortField = 'role'
originalOwnerId: string | null = null
): 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
@@ -120,8 +97,7 @@ export function useMembersPanel() {
showRevokeInviteDialog,
showChangeMemberRoleDialog,
showInviteMemberDialog,
showInviteMemberUpsellDialog,
showMemberLimitDialog
showInviteMemberUpsellDialog
} = useDialogService()
const workspaceStore = useTeamWorkspaceStore()
const {
@@ -136,14 +112,11 @@ 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(
@@ -165,16 +138,16 @@ export function useMembersPanel() {
() => isInviteLimitReached.value || totalMemberSlots.value >= maxSeats.value
)
// 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.
// Invite is allowed only on an active (non-cancelled) team plan that is under
// the member cap.
const isInviteDisabled = computed(
() => !isOnTeamPlan.value || isCancelled.value
() => !isOnTeamPlan.value || isCancelled.value || isMemberLimitReached.value
)
const inviteTooltip = computed(() => {
if (!isOnTeamPlan.value) return null
if (!isMemberLimitReached.value) return null
return t('workspacePanel.inviteLimitReached')
return t('workspacePanel.inviteLimitReached', { count: maxSeats.value })
})
function handleInviteMember() {
@@ -182,11 +155,7 @@ export function useMembersPanel() {
void showInviteMemberUpsellDialog()
return
}
if (isCancelled.value) return
if (isMemberLimitReached.value) {
void showMemberLimitDialog()
return
}
if (isCancelled.value || isMemberLimitReached.value) return
void showInviteMemberDialog()
}
@@ -221,7 +190,7 @@ export function useMembersPanel() {
{
label: t('workspacePanel.members.actions.changeRole'),
items: [
roleMenuItem(member, 'owner', t('workspaceSwitcher.roleAdmin')),
roleMenuItem(member, 'owner', t('workspaceSwitcher.roleOwner')),
roleMenuItem(member, 'member', t('workspaceSwitcher.roleMember'))
]
},
@@ -246,14 +215,13 @@ export function useMembersPanel() {
searched,
userEmail.value ?? null,
sortDirection.value,
originalOwnerId.value,
sortField.value
originalOwnerId.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 MemberTableRow's props.
// row's menu and churn MemberListItem's props.
const memberMenus = computed(
() => new Map(filteredMembers.value.map((m) => [m.id, memberMenuItems(m)]))
)
@@ -318,11 +286,9 @@ export function useMembersPanel() {
sortField,
sortDirection,
maxSeats,
memberCount,
isOnTeamPlan,
hasLapsedTeamPlan,
hasMultipleMembers,
fetchBalance,
showSearch,
showViewTabs,
showInviteButton,

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
* Plan & Credits panel menu.
* WorkspacePanelContent menu.
*/
export function useWorkspaceMenuItems() {
const { t } = useI18n()

View File

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

View File

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

View File

@@ -78,9 +78,7 @@ function getPermissions(
// member role
return {
canViewOtherMembers: true,
// Members can see who's been invited (view-only); they still can't
// resend/revoke (canManageInvites) or invite (canInviteMembers).
canViewPendingInvites: true,
canViewPendingInvites: false,
canInviteMembers: false,
canManageInvites: false,
canManageMembers: false,
@@ -130,7 +128,7 @@ function getUIConfig(
// member role
return {
showMembersList: true,
showPendingTab: true,
showPendingTab: false,
showSearch: true,
showRoleColumn: true,
membersGridCols: 'grid-cols-[1fr_auto]',

View File

@@ -1463,8 +1463,8 @@ describe('useTeamWorkspaceStore', () => {
expect(store.isInviteLimitReached).toBe(false)
})
it('isInviteLimitReached enforces the flat 50-member backend cap, independent of plan seats', async () => {
const mockMembers = Array.from({ length: 48 }, (_, i) => ({
it('isInviteLimitReached enforces the flat 30-member backend cap, independent of plan seats', async () => {
const mockMembers = Array.from({ length: 28 }, (_, i) => ({
id: `user-${i}`,
name: `User ${i}`,
email: `user${i}@test.com`,
@@ -1488,7 +1488,7 @@ describe('useTeamWorkspaceStore', () => {
]
mockWorkspaceApi.listMembers.mockResolvedValue({
members: mockMembers,
pagination: { offset: 0, limit: 50, total: 48 }
pagination: { offset: 0, limit: 50, total: 28 }
})
mockWorkspaceApi.listInvites.mockResolvedValue({ invites: mockInvites })
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
@@ -1499,7 +1499,7 @@ describe('useTeamWorkspaceStore', () => {
await store.fetchMembers()
await store.fetchPendingInvites()
expect(store.totalMemberSlots).toBe(50)
expect(store.totalMemberSlots).toBe(30)
expect(store.isInviteLimitReached).toBe(true)
})
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,3 @@ export const WORKSPACE_STORAGE_KEYS = {
} as const
export const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000
/** Max length for a workspace name (create, rename, edit). */
export const WORKSPACE_NAME_MAX_LENGTH = 30

View File

@@ -16,6 +16,7 @@ import { useUserStore } from '@/stores/userStore'
import LayoutDefault from '@/views/layouts/LayoutDefault.vue'
import { captureOAuthRequestId } from '@/platform/cloud/oauth/oauthState'
import { installDesktopLoginRedemption } from '@/platform/cloud/onboarding/desktopLoginRedemption'
import { installPreservedQueryTracker } from '@/platform/navigation/preservedQueryTracker'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { preserveLoggedOutShareAuthAttribution } from '@/platform/workflow/sharing/utils/shareAuthAttribution'
@@ -118,6 +119,11 @@ installPreservedQueryTracker(router, [
{
namespace: PRESERVED_QUERY_NAMESPACES.PRICING,
keys: ['pricing']
},
{
namespace: PRESERVED_QUERY_NAMESPACES.DESKTOP_LOGIN,
keys: ['desktop_login_code'],
stripAfterCapture: true
}
])
@@ -249,6 +255,8 @@ if (isCloud) {
// User is logged in and accessing protected route
return next()
})
installDesktopLoginRedemption(router)
}
export default router

View File

@@ -10,7 +10,6 @@ import { t } from '@/i18n'
import { useTelemetry } from '@/platform/telemetry'
import { isCloud } from '@/platform/distribution/types'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useExternalLink } from '@/composables/useExternalLink'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDialogStore } from '@/stores/dialogStore'
import type {
@@ -583,46 +582,6 @@ export const useDialogService = () => {
})
}
async function showMemberLimitDialog() {
const { default: component } =
await import('@/platform/workspace/components/dialogs/RequestMoreDialogContent.vue')
const { staticUrls } = useExternalLink()
return dialogStore.showDialog({
key: 'member-limit',
component,
props: {
dialogKey: 'member-limit',
title: t('workspacePanel.memberLimitDialog.title'),
message: t('workspacePanel.memberLimitDialog.message'),
onRequestMore: () =>
window.open(
staticUrls.teamPlanRequests,
'_blank',
'noopener,noreferrer'
)
},
dialogComponentProps: workspaceDialogProps
})
}
async function showWorkflowQueuedDialog() {
const { default: component } =
await import('@/platform/workspace/components/dialogs/RequestMoreDialogContent.vue')
const { staticUrls } = useExternalLink()
return dialogStore.showDialog({
key: 'workflow-queued',
component,
props: {
dialogKey: 'workflow-queued',
title: t('workspacePanel.workflowQueuedDialog.title'),
message: t('workspacePanel.workflowQueuedDialog.message'),
onRequestMore: () =>
window.open(staticUrls.discord, '_blank', 'noopener,noreferrer')
},
dialogComponentProps: workspaceDialogProps
})
}
async function showRevokeInviteDialog(inviteId: string) {
const { default: component } =
await import('@/platform/workspace/components/dialogs/RevokeInviteDialogContent.vue')
@@ -773,8 +732,6 @@ export const useDialogService = () => {
showRevokeInviteDialog,
showInviteMemberDialog,
showInviteMemberUpsellDialog,
showMemberLimitDialog,
showWorkflowQueuedDialog,
showBillingComingSoonDialog,
showCancelSubscriptionDialog,
showDowngradeToPersonalDialog

View File

@@ -18,6 +18,7 @@ import { createHtmlPlugin } from 'vite-plugin-html'
import vueDevTools from 'vite-plugin-vue-devtools'
import { comfyAPIPlugin } from './build/plugins'
import { CRITICAL_COVERAGE_DIRS } from './scripts/critical-coverage/criticalCoverageDirs'
dotenvConfig()
@@ -30,46 +31,6 @@ const DISABLE_TEMPLATES_PROXY = process.env.DISABLE_TEMPLATES_PROXY === 'true'
const GENERATE_SOURCEMAP = process.env.GENERATE_SOURCEMAP !== 'false'
const IS_STORYBOOK = process.env.npm_lifecycle_event === 'storybook'
const CRITICAL_COVERAGE_DIRS = [
'src/base',
'src/composables',
'src/core',
'src/lib/litegraph/src/node',
'src/lib/litegraph/src/subgraph',
'src/lib/litegraph/src/utils',
'src/platform/assets/composables',
'src/platform/assets/mappings',
'src/platform/assets/schemas',
'src/platform/assets/services',
'src/platform/assets/utils',
'src/platform/errorCatalog',
'src/platform/keybindings',
'src/platform/missingMedia',
'src/platform/missingModel',
'src/platform/navigation',
'src/platform/nodeReplacement',
'src/platform/remote',
'src/platform/remoteConfig',
'src/platform/secrets',
'src/platform/settings',
'src/platform/workflow',
'src/platform/workspace/api',
'src/platform/workspace/auth',
'src/platform/workspace/composables',
'src/platform/workspace/stores',
'src/platform/workspace/utils',
'src/schemas',
'src/scripts',
'src/services',
'src/stores',
'src/utils',
'src/workbench/extensions/manager/composables',
'src/workbench/extensions/manager/services',
'src/workbench/extensions/manager/stores',
'src/workbench/extensions/manager/utils',
'src/workbench/utils'
]
// A single glob key so vitest aggregates all critical dirs into one
// thresholds bucket instead of one bucket per glob
const CRITICAL_COVERAGE_GLOB = `{${CRITICAL_COVERAGE_DIRS.join(',')}}/**/*.{ts,vue}`