[backport cloud/1.37] Workspaces 4 members invites (#8301)

## Summary

Backport of #8245 to cloud/1.37.

Add team workspace member management and invite system.

- Add members panel with role management (owner/admin/member) and member
removal
- Add invite system with email invites, pending invite display, and
revoke functionality
- Add invite URL loading for accepting invites
- Add subscription panel updates for member management
- Add i18n translations for member and invite features

## Conflict Resolution

- `src/components/dialog/GlobalDialog.vue`: Added missing
`DialogPassThroughOptions` import
- `src/locales/en/main.json`: Kept "nightly" section from main (was
present before PR)
- `src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts`:
Deleted (file doesn't exist in cloud/1.37, only contains unrelated
method rename)

(cherry picked from commit 4771565486)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8301-backport-cloud-1-37-Workspaces-4-members-invites-2f36d73d36508119a388dac9d290efbd)
by [Unito](https://www.unito.io)
This commit is contained in:
Simula_r
2026-01-24 19:05:05 -08:00
committed by GitHub
parent 3b7e102b52
commit 1963f28429
32 changed files with 1791 additions and 129 deletions

View File

@@ -9,7 +9,7 @@ test.describe('Properties panel', () => {
const { propertiesPanel } = comfyPage.menu
await expect(propertiesPanel.panelTitle).toContainText(
'No node(s) selected'
'No item(s) selected'
)
await comfyPage.selectNodes(['KSampler', 'CLIP Text Encode (Prompt)'])

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex size-6 items-center justify-center rounded-md text-base font-semibold text-white"
class="flex 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)'

View File

@@ -11,7 +11,7 @@
: ''
]"
v-bind="item.dialogComponentProps"
:pt="item.dialogComponentProps.pt"
:pt="getDialogPt(item)"
:aria-labelledby="item.key"
>
<template #header>
@@ -41,11 +41,14 @@
</template>
<script setup lang="ts">
import { merge } from 'es-toolkit/compat'
import Dialog from 'primevue/dialog'
import type { DialogPassThroughOptions } from 'primevue/dialog'
import { computed } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import type { DialogComponentProps } from '@/stores/dialogStore'
import { useDialogStore } from '@/stores/dialogStore'
const { flags } = useFeatureFlags()
@@ -54,6 +57,22 @@ const teamWorkspacesEnabled = computed(
)
const dialogStore = useDialogStore()
function getDialogPt(item: {
key: string
dialogComponentProps: DialogComponentProps
}): DialogPassThroughOptions {
const isWorkspaceSettingsDialog =
item.key === 'global-settings' && teamWorkspacesEnabled.value
const basePt = item.dialogComponentProps.pt || {}
if (isWorkspaceSettingsDialog) {
return merge(basePt, {
mask: { class: 'p-8' }
})
}
return basePt
}
</script>
<style>
@@ -73,9 +92,12 @@ const dialogStore = useDialogStore()
.settings-dialog-workspace {
width: 100%;
max-width: 1440px;
height: 100%;
}
.settings-dialog-workspace .p-dialog-content {
width: 100%;
height: 100%;
overflow-y: auto;
}
</style>

View File

@@ -31,7 +31,12 @@
}}</label>
</div>
<Button variant="secondary" autofocus @click="onCancel">
<Button
v-if="type !== 'info'"
variant="secondary"
autofocus
@click="onCancel"
>
<i class="pi pi-undo" />
{{ $t('g.cancel') }}
</Button>
@@ -73,6 +78,10 @@
<i class="pi pi-eraser" />
{{ $t('desktopMenu.reinstall') }}
</Button>
<!-- Info - just show an OK button -->
<Button v-else-if="type === 'info'" variant="primary" @click="onCancel">
{{ $t('g.ok') }}
</Button>
<!-- Invalid - just show a close button. -->
<Button v-else variant="primary" @click="onCancel">
<i class="pi pi-times" />

View File

@@ -0,0 +1,511 @@
<template>
<div class="grow overflow-auto pt-6">
<div
class="flex size-full flex-col gap-2 rounded-2xl border border-interface-stroke border-inter p-6"
>
<!-- Section Header -->
<div class="flex w-full items-center gap-9">
<div class="flex min-w-0 flex-1 items-baseline gap-2">
<span
v-if="uiConfig.showMembersList"
class="text-base font-semibold text-base-foreground"
>
<template v-if="activeView === 'active'">
{{
$t('workspacePanel.members.membersCount', {
count: members.length
})
}}
</template>
<template v-else-if="permissions.canViewPendingInvites">
{{
$t(
'workspacePanel.members.pendingInvitesCount',
pendingInvites.length
)
}}
</template>
</span>
</div>
<div v-if="uiConfig.showSearch" class="flex items-start gap-2">
<SearchBox
v-model="searchQuery"
:placeholder="$t('g.search')"
size="lg"
class="w-64"
/>
</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"
: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('joinDate')"
>
{{ $t('workspacePanel.members.columns.joinDate') }}
<i class="icon-[lucide--chevrons-up-down] size-4" />
</Button>
<!-- Empty cell for action column header (OWNER only) -->
<div v-if="permissions.canRemoveMembers" />
</template>
</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">
<div
:class="
cn(
'grid w-full items-center rounded-lg p-2',
uiConfig.membersGridCols
)
"
>
<div class="flex items-center gap-3">
<UserAvatar
class="size-8"
:photo-url="userPhotoUrl"
:pt:icon:class="{ 'text-xl!': !userPhotoUrl }"
/>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<div class="flex items-center gap-2">
<span class="text-sm text-base-foreground">
{{ userDisplayName }}
<span class="text-muted-foreground">
({{ $t('g.you') }})
</span>
</span>
<span
v-if="uiConfig.showRoleBadge"
class="text-[10px] font-bold uppercase text-base-background bg-base-foreground px-1 py-0.5 rounded-full"
>
{{ $t('workspaceSwitcher.roleOwner') }}
</span>
</div>
<span class="text-sm text-muted-foreground">
{{ userEmail }}
</span>
</div>
</div>
</div>
</template>
<!-- Team Workspace: sorted list (owner first, current user second, then rest) -->
<template v-else>
<div
v-for="(member, index) in filteredMembers"
:key="member.id"
:class="
cn(
'grid w-full items-center rounded-lg p-2',
uiConfig.membersGridCols,
index % 2 === 1 && 'bg-secondary-background/50'
)
"
>
<div class="flex items-center gap-3">
<UserAvatar
class="size-8"
:photo-url="
isCurrentUser(member) ? userPhotoUrl : undefined
"
:pt:icon:class="{
'text-xl!': !isCurrentUser(member) || !userPhotoUrl
}"
/>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<div class="flex items-center gap-2">
<span class="text-sm text-base-foreground">
{{ member.name }}
<span
v-if="isCurrentUser(member)"
class="text-muted-foreground"
>
({{ $t('g.you') }})
</span>
</span>
<span
v-if="uiConfig.showRoleBadge"
class="text-[10px] font-bold uppercase text-base-background bg-base-foreground px-1 py-0.5 rounded-full"
>
{{ getRoleBadgeLabel(member.role) }}
</span>
</div>
<span class="text-sm text-muted-foreground">
{{ member.email }}
</span>
</div>
</div>
<!-- Join date -->
<span
v-if="uiConfig.showDateColumn"
class="text-sm text-muted-foreground text-right"
>
{{ formatDate(member.joinDate) }}
</span>
<!-- Remove member action (OWNER only, can't remove yourself) -->
<div
v-if="permissions.canRemoveMembers"
class="flex items-center justify-end"
>
<Button
v-if="!isCurrentUser(member)"
v-tooltip="{
value: $t('g.moreOptions'),
showDelay: 300
}"
variant="muted-textonly"
size="icon"
:aria-label="$t('g.moreOptions')"
@click="showMemberMenu($event, member)"
>
<i class="pi pi-ellipsis-h" />
</Button>
</div>
</div>
<!-- Member actions menu (shared for all members) -->
<Menu ref="memberMenu" :model="memberMenuItems" :popup="true" />
</template>
</template>
<!-- Pending Invites -->
<template v-else>
<div
v-for="(invite, index) in filteredPendingInvites"
:key="invite.id"
:class="
cn(
'grid w-full items-center rounded-lg p-2',
uiConfig.pendingGridCols,
index % 2 === 1 && 'bg-secondary-background/50'
)
"
>
<!-- Invite info -->
<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>
<!-- Invite date -->
<span class="text-sm text-muted-foreground">
{{ formatDate(invite.inviteDate) }}
</span>
<!-- Expiry date -->
<span class="text-sm text-muted-foreground">
{{ formatDate(invite.expiryDate) }}
</span>
<!-- Actions -->
<div class="flex items-center justify-end gap-2">
<Button
v-tooltip="{
value: $t('workspacePanel.members.actions.copyLink'),
showDelay: 300
}"
variant="secondary"
size="md"
:aria-label="$t('workspacePanel.members.actions.copyLink')"
@click="handleCopyInviteLink(invite)"
>
<i class="icon-[lucide--link] size-4" />
</Button>
<Button
v-tooltip="{
value: $t('workspacePanel.members.actions.revokeInvite'),
showDelay: 300
}"
variant="secondary"
size="md"
:aria-label="
$t('workspacePanel.members.actions.revokeInvite')
"
@click="handleRevokeInvite(invite)"
>
<i class="icon-[lucide--mail-x] size-4" />
</Button>
</div>
</div>
<div
v-if="filteredPendingInvites.length === 0"
class="flex w-full items-center justify-center py-8 text-sm text-muted-foreground"
>
{{ $t('workspacePanel.members.noInvites') }}
</div>
</template>
</div>
</div>
</div>
<!-- Personal Workspace Message -->
<div v-if="isPersonalWorkspace" class="flex items-center">
<p class="text-sm text-muted-foreground">
{{ $t('workspacePanel.members.personalWorkspaceMessage') }}
</p>
<button
class="underline bg-transparent border-none cursor-pointer"
@click="handleCreateWorkspace"
>
{{ $t('workspacePanel.members.createNewWorkspace') }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import Menu from 'primevue/menu'
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchBox from '@/components/common/SearchBox.vue'
import UserAvatar from '@/components/common/UserAvatar.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import type {
PendingInvite,
WorkspaceMember
} from '@/platform/workspace/stores/teamWorkspaceStore'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogService } from '@/services/dialogService'
import { cn } from '@/utils/tailwindUtil'
const { d, t } = useI18n()
const toast = useToast()
const { userPhotoUrl, userEmail, userDisplayName } = useCurrentUser()
const {
showRemoveMemberDialog,
showRevokeInviteDialog,
showCreateWorkspaceDialog
} = useDialogService()
const workspaceStore = useTeamWorkspaceStore()
const {
members,
pendingInvites,
isInPersonalWorkspace: isPersonalWorkspace
} = storeToRefs(workspaceStore)
const { copyInviteLink } = workspaceStore
const { permissions, uiConfig } = useWorkspaceUI()
const searchQuery = ref('')
const activeView = ref<'active' | 'pending'>('active')
const sortField = ref<'inviteDate' | 'expiryDate' | 'joinDate'>('inviteDate')
const sortDirection = ref<'asc' | 'desc'>('desc')
const memberMenu = ref<InstanceType<typeof Menu> | null>(null)
const selectedMember = ref<WorkspaceMember | null>(null)
function getInviteDisplayName(email: string): string {
return email.split('@')[0]
}
function getInviteInitial(email: string): string {
return email.charAt(0).toUpperCase()
}
const memberMenuItems = computed(() => [
{
label: t('workspacePanel.members.actions.removeMember'),
icon: 'pi pi-user-minus',
command: () => {
if (selectedMember.value) {
handleRemoveMember(selectedMember.value)
}
}
}
])
function showMemberMenu(event: Event, member: WorkspaceMember) {
selectedMember.value = member
memberMenu.value?.toggle(event)
}
function isCurrentUser(member: WorkspaceMember): boolean {
return member.email.toLowerCase() === userEmail.value?.toLowerCase()
}
// All members sorted: owners first, current user second, then rest by join date
const filteredMembers = computed(() => {
let result = [...members.value]
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter(
(member) =>
member.name.toLowerCase().includes(query) ||
member.email.toLowerCase().includes(query)
)
}
result.sort((a, b) => {
// Owners always come first
if (a.role === 'owner' && b.role !== 'owner') return -1
if (a.role !== 'owner' && b.role === 'owner') return 1
// Current user comes second (after owner)
const aIsCurrentUser = isCurrentUser(a)
const bIsCurrentUser = isCurrentUser(b)
if (aIsCurrentUser && !bIsCurrentUser) return -1
if (!aIsCurrentUser && bIsCurrentUser) return 1
// Then sort by join date
const aValue = a.joinDate.getTime()
const bValue = b.joinDate.getTime()
return sortDirection.value === 'asc' ? aValue - bValue : bValue - aValue
})
return result
})
function getRoleBadgeLabel(role: 'owner' | 'member'): string {
return role === 'owner'
? t('workspaceSwitcher.roleOwner')
: t('workspaceSwitcher.roleMember')
}
const filteredPendingInvites = computed(() => {
let result = [...pendingInvites.value]
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter((invite) =>
invite.email.toLowerCase().includes(query)
)
}
const field = sortField.value === 'joinDate' ? 'inviteDate' : sortField.value
result.sort((a, b) => {
const aDate = a[field]
const bDate = b[field]
if (!aDate || !bDate) return 0
const aValue = aDate.getTime()
const bValue = bDate.getTime()
return sortDirection.value === 'asc' ? aValue - bValue : bValue - aValue
})
return result
})
function toggleSort(field: 'inviteDate' | 'expiryDate' | 'joinDate') {
if (sortField.value === field) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
} else {
sortField.value = field
sortDirection.value = 'desc'
}
}
function formatDate(date: Date): string {
return d(date, { dateStyle: 'medium' })
}
async function handleCopyInviteLink(invite: PendingInvite) {
try {
await copyInviteLink(invite.id)
toast.add({
severity: 'success',
summary: t('g.copied'),
life: 2000
})
} catch {
toast.add({
severity: 'error',
summary: t('g.error'),
life: 3000
})
}
}
function handleRevokeInvite(invite: PendingInvite) {
showRevokeInviteDialog(invite.id)
}
function handleCreateWorkspace() {
showCreateWorkspaceDialog()
}
function handleRemoveMember(member: WorkspaceMember) {
showRemoveMemberDialog(member.id)
}
</script>

View File

@@ -9,17 +9,66 @@
{{ workspaceName }}
</h1>
</div>
<Tabs :value="activeTab" @update:value="setActiveTab">
<Tabs unstyled :value="activeTab" @update:value="setActiveTab">
<div class="flex w-full items-center">
<TabList class="w-full">
<Tab value="plan">{{ $t('workspacePanel.tabs.planCredits') }}</Tab>
<TabList unstyled class="flex w-full gap-2">
<Tab
value="plan"
:class="
cn(
buttonVariants({
variant: activeTab === 'plan' ? 'secondary' : 'textonly',
size: 'md'
}),
activeTab === 'plan' && 'text-base-foreground no-underline'
)
"
>
{{ $t('workspacePanel.tabs.planCredits') }}
</Tab>
<Tab
value="members"
:class="
cn(
buttonVariants({
variant: activeTab === 'members' ? 'secondary' : 'textonly',
size: 'md'
}),
activeTab === 'members' && 'text-base-foreground no-underline',
'ml-2'
)
"
>
{{
$t('workspacePanel.tabs.membersCount', {
count: isInPersonalWorkspace ? 1 : members.length
})
}}
</Tab>
</TabList>
<Button
v-if="permissions.canInviteMembers"
v-tooltip="
inviteTooltip
? { value: inviteTooltip, showDelay: 0 }
: { value: $t('workspacePanel.inviteMember'), showDelay: 300 }
"
variant="secondary"
size="lg"
:disabled="isInviteLimitReached"
:class="isInviteLimitReached && 'opacity-50 cursor-not-allowed'"
:aria-label="$t('workspacePanel.inviteMember')"
@click="handleInviteMember"
>
{{ $t('workspacePanel.invite') }}
<i class="pi pi-plus ml-1 text-sm" />
</Button>
<template v-if="permissions.canAccessWorkspaceMenu">
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="muted-textonly"
size="icon"
class="ml-2"
variant="secondary"
size="lg"
:aria-label="$t('g.moreOptions')"
@click="menu?.toggle($event)"
>
@@ -36,7 +85,7 @@
:class="[
'flex items-center gap-2 px-3 py-2',
item.class,
item.disabled ? 'pointer-events-auto' : ''
item.disabled ? 'pointer-events-auto' : 'cursor-pointer'
]"
@click="
item.command?.({
@@ -53,9 +102,12 @@
</template>
</div>
<TabPanels>
<TabPanels unstyled>
<TabPanel value="plan">
<SubscriptionPanelContent />
<SubscriptionPanelContentWorkspace />
</TabPanel>
<TabPanel value="members">
<MembersPanelContent :key="workspaceRole" />
</TabPanel>
</TabPanels>
</Tabs>
@@ -74,11 +126,14 @@ import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import MembersPanelContent from '@/components/dialog/content/setting/MembersPanelContent.vue'
import Button from '@/components/ui/button/Button.vue'
import SubscriptionPanelContent from '@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue'
import { buttonVariants } from '@/components/ui/button/button.variants'
import SubscriptionPanelContentWorkspace from '@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogService } from '@/services/dialogService'
import { cn } from '@/utils/tailwindUtil'
const { defaultTab = 'plan' } = defineProps<{
defaultTab?: string
@@ -88,12 +143,20 @@ const { t } = useI18n()
const {
showLeaveWorkspaceDialog,
showDeleteWorkspaceDialog,
showInviteMemberDialog,
showEditWorkspaceDialog
} = useDialogService()
const workspaceStore = useTeamWorkspaceStore()
const { workspaceName, isWorkspaceSubscribed } = storeToRefs(workspaceStore)
const { activeTab, setActiveTab, permissions, uiConfig } = useWorkspaceUI()
const {
workspaceName,
members,
isInviteLimitReached,
isWorkspaceSubscribed,
isInPersonalWorkspace
} = storeToRefs(workspaceStore)
const { fetchMembers, fetchPendingInvites } = workspaceStore
const { activeTab, setActiveTab, workspaceRole, permissions, uiConfig } =
useWorkspaceUI()
const menu = ref<InstanceType<typeof Menu> | null>(null)
@@ -123,6 +186,16 @@ const deleteTooltip = computed(() => {
return tooltipKey ? t(tooltipKey) : null
})
const inviteTooltip = computed(() => {
if (!isInviteLimitReached.value) return null
return t('workspacePanel.inviteLimitReached')
})
function handleInviteMember() {
if (isInviteLimitReached.value) return
showInviteMemberDialog()
}
const menuItems = computed(() => {
const items = []
@@ -159,5 +232,7 @@ const menuItems = computed(() => {
onMounted(() => {
setActiveTab(defaultTab)
fetchMembers()
fetchPendingInvites()
})
</script>

View File

@@ -79,8 +79,7 @@ const workspaceName = ref('')
const isValidName = computed(() => {
const name = workspaceName.value.trim()
// Allow alphanumeric, spaces, hyphens, underscores (safe characters)
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_]*$/
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_'.,()&+]*$/
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
})

View File

@@ -69,7 +69,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\-_]*$/
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_'.,()&+]*$/
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
})

View File

@@ -0,0 +1,182 @@
<template>
<div
class="flex w-full max-w-[512px] flex-col rounded-2xl border border-border-default bg-base-background"
>
<!-- Header -->
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h2 class="m-0 text-sm font-normal text-base-foreground">
{{
step === 'email'
? $t('workspacePanel.inviteMemberDialog.title')
: $t('workspacePanel.inviteMemberDialog.linkStep.title')
}}
</h2>
<button
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
:aria-label="$t('g.close')"
@click="onCancel"
>
<i class="pi pi-times size-4" />
</button>
</div>
<!-- Body: Email Step -->
<template v-if="step === 'email'">
<div class="flex flex-col gap-4 px-4 py-4">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.inviteMemberDialog.message') }}
</p>
<input
v-model="email"
type="email"
class="w-full rounded-lg border border-border-default bg-transparent px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-secondary-foreground"
:placeholder="$t('workspacePanel.inviteMemberDialog.placeholder')"
/>
</div>
<!-- Footer: Email Step -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" @click="onCancel">
{{ $t('g.cancel') }}
</Button>
<Button
variant="primary"
size="lg"
:loading
:disabled="!isValidEmail"
@click="onCreateLink"
>
{{ $t('workspacePanel.inviteMemberDialog.createLink') }}
</Button>
</div>
</template>
<!-- Body: Link Step -->
<template v-else>
<div class="flex flex-col gap-4 px-4 py-4">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.inviteMemberDialog.linkStep.message') }}
</p>
<p class="m-0 text-sm font-medium text-base-foreground">
{{ email }}
</p>
<div class="relative">
<input
:value="generatedLink"
readonly
class="w-full cursor-pointer rounded-lg border border-border-default bg-transparent px-3 py-2 pr-10 text-sm text-base-foreground focus:outline-none"
@click="onSelectLink"
/>
<div
class="absolute right-4 top-2 cursor-pointer"
@click="onCopyLink"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
>
<g clip-path="url(#clip0_2127_14348)">
<path
d="M2.66634 10.6666C1.93301 10.6666 1.33301 10.0666 1.33301 9.33325V2.66659C1.33301 1.93325 1.93301 1.33325 2.66634 1.33325H9.33301C10.0663 1.33325 10.6663 1.93325 10.6663 2.66659M6.66634 5.33325H13.333C14.0694 5.33325 14.6663 5.93021 14.6663 6.66658V13.3333C14.6663 14.0696 14.0694 14.6666 13.333 14.6666H6.66634C5.92996 14.6666 5.33301 14.0696 5.33301 13.3333V6.66658C5.33301 5.93021 5.92996 5.33325 6.66634 5.33325Z"
stroke="white"
stroke-width="1.3"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_2127_14348">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
</div>
</div>
</div>
<!-- Footer: Link Step -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" @click="onCancel">
{{ $t('g.cancel') }}
</Button>
<Button variant="primary" size="lg" @click="onCopyLink">
{{ $t('workspacePanel.inviteMemberDialog.linkStep.copyLink') }}
</Button>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const dialogStore = useDialogStore()
const toast = useToast()
const { t } = useI18n()
const workspaceStore = useTeamWorkspaceStore()
const loading = ref(false)
const email = ref('')
const step = ref<'email' | 'link'>('email')
const generatedLink = ref('')
const isValidEmail = computed(() => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email.value)
})
function onCancel() {
dialogStore.closeDialog({ key: 'invite-member' })
}
async function onCreateLink() {
if (!isValidEmail.value) return
loading.value = true
try {
generatedLink.value = await workspaceStore.createInviteLink(email.value)
step.value = 'link'
} catch (error) {
toast.add({
severity: 'error',
summary: t('workspacePanel.inviteMemberDialog.linkCopyFailed'),
detail: error instanceof Error ? error.message : undefined,
life: 3000
})
} finally {
loading.value = false
}
}
async function onCopyLink() {
try {
await navigator.clipboard.writeText(generatedLink.value)
toast.add({
severity: 'success',
summary: t('workspacePanel.inviteMemberDialog.linkCopied'),
life: 2000
})
} catch {
toast.add({
severity: 'error',
summary: t('workspacePanel.inviteMemberDialog.linkCopyFailed'),
life: 3000
})
}
}
function onSelectLink(event: Event) {
const input = event.target as HTMLInputElement
input.select()
}
</script>

View File

@@ -0,0 +1,83 @@
<template>
<div
class="flex w-full max-w-[360px] flex-col rounded-2xl border border-border-default bg-base-background"
>
<!-- Header -->
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h2 class="m-0 text-sm font-normal text-base-foreground">
{{ $t('workspacePanel.removeMemberDialog.title') }}
</h2>
<button
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
:aria-label="$t('g.close')"
@click="onCancel"
>
<i class="pi pi-times size-4" />
</button>
</div>
<!-- Body -->
<div class="px-4 py-4">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.removeMemberDialog.message') }}
</p>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" @click="onCancel">
{{ $t('g.cancel') }}
</Button>
<Button variant="destructive" size="lg" :loading @click="onRemove">
{{ $t('workspacePanel.removeMemberDialog.remove') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const { memberId } = defineProps<{
memberId: string
}>()
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
const toast = useToast()
const { t } = useI18n()
const loading = ref(false)
function onCancel() {
dialogStore.closeDialog({ key: 'remove-member' })
}
async function onRemove() {
loading.value = true
try {
await workspaceStore.removeMember(memberId)
toast.add({
severity: 'success',
summary: t('workspacePanel.removeMemberDialog.success'),
life: 2000
})
dialogStore.closeDialog({ key: 'remove-member' })
} catch {
toast.add({
severity: 'error',
summary: t('workspacePanel.removeMemberDialog.error'),
life: 3000
})
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,79 @@
<template>
<div
class="flex w-full max-w-[360px] flex-col rounded-2xl border border-border-default bg-base-background"
>
<!-- Header -->
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h2 class="m-0 text-sm font-normal text-base-foreground">
{{ $t('workspacePanel.revokeInviteDialog.title') }}
</h2>
<button
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
:aria-label="$t('g.close')"
@click="onCancel"
>
<i class="pi pi-times size-4" />
</button>
</div>
<!-- Body -->
<div class="px-4 py-4">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.revokeInviteDialog.message') }}
</p>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" @click="onCancel">
{{ $t('g.cancel') }}
</Button>
<Button variant="destructive" size="lg" :loading @click="onRevoke">
{{ $t('workspacePanel.revokeInviteDialog.revoke') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const { inviteId } = defineProps<{
inviteId: string
}>()
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
const toast = useToast()
const { t } = useI18n()
const loading = ref(false)
function onCancel() {
dialogStore.closeDialog({ key: 'revoke-invite' })
}
async function onRevoke() {
loading.value = true
try {
await workspaceStore.revokeInvite(inviteId)
dialogStore.closeDialog({ key: 'revoke-invite' })
} catch (error) {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: error instanceof Error ? error.message : undefined,
life: 3000
})
} finally {
loading.value = false
}
}
</script>

View File

@@ -126,11 +126,13 @@ import { useNodeBadge } from '@/composables/node/useNodeBadge'
import { useCanvasDrop } from '@/composables/useCanvasDrop'
import { useContextMenuTranslation } from '@/composables/useContextMenuTranslation'
import { useCopy } from '@/composables/useCopy'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
import { usePaste } from '@/composables/usePaste'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { mergeCustomNodesI18n, t } from '@/i18n'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { isCloud } from '@/platform/distribution/types'
import { useLitegraphSettings } from '@/platform/settings/composables/useLitegraphSettings'
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -139,6 +141,7 @@ import { useWorkflowService } from '@/platform/workflow/core/services/workflowSe
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables/useWorkflowAutoSave'
import { useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistence'
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
@@ -394,6 +397,9 @@ const loadCustomNodesI18n = async () => {
const comfyAppReady = ref(false)
const workflowPersistence = useWorkflowPersistence()
const { flags } = useFeatureFlags()
// Set up invite loader during setup phase so useRoute/useRouter work correctly
const inviteUrlLoader = isCloud ? useInviteUrlLoader() : null
useCanvasDrop(canvasRef)
useLitegraphSettings()
useNodeBadge()
@@ -459,6 +465,22 @@ onMounted(async () => {
// Load template from URL if present
await workflowPersistence.loadTemplateFromUrlIfPresent()
// Accept workspace invite from URL if present (e.g., ?invite=TOKEN)
// Uses watch because feature flags load asynchronously - flag may be false initially
// then become true once remoteConfig or websocket features are loaded
if (inviteUrlLoader) {
const stopWatching = watch(
() => flags.teamWorkspacesEnabled,
async (enabled) => {
if (enabled) {
stopWatching()
await inviteUrlLoader.loadInviteFromUrl()
}
},
{ immediate: true }
)
}
// Initialize release store to fetch releases from comfy-api (fire-and-forget)
const { useReleaseStore } =
await import('@/platform/updates/common/releaseStore')

View File

@@ -27,7 +27,7 @@
:class="compact && 'size-full'"
/>
<i v-if="showArrow" class="icon-[lucide--chevron-down] size-3 px-1" />
<i v-if="showArrow" class="icon-[lucide--chevron-down] size-4 px-1" />
</div>
</Button>

View File

@@ -36,15 +36,6 @@
<span class="truncate text-sm text-base-foreground">{{
workspaceName
}}</span>
<div
v-if="workspaceTierName"
class="shrink-0 rounded bg-secondary-background-hover px-1.5 py-0.5 text-xs"
>
{{ workspaceTierName }}
</div>
<span v-else class="shrink-0 text-xs text-muted-foreground">
{{ $t('workspaceSwitcher.subscribe') }}
</span>
</div>
<i class="pi pi-chevron-down shrink-0 text-sm text-muted-foreground" />
</div>
@@ -92,15 +83,23 @@
>
{{ $t('subscription.addCredits') }}
</Button>
<!-- Unsubscribed: Show Subscribe button (disabled until billing is ready) -->
<!-- Unsubscribed: Show Subscribe button -->
<SubscribeButton
v-else
disabled
v-else-if="isPersonalWorkspace"
:fluid="false"
:label="$t('workspaceSwitcher.subscribe')"
size="sm"
variant="gradient"
/>
<!-- Non-personal workspace: Navigate to workspace settings -->
<Button
v-else
variant="primary"
size="sm"
@click="handleOpenPlanAndCreditsSettings"
>
{{ $t('workspaceSwitcher.subscribe') }}
</Button>
</div>
<Divider class="mx-0 my-2" />
@@ -198,7 +197,6 @@ import Divider from 'primevue/divider'
import Popover from 'primevue/popover'
import Skeleton from 'primevue/skeleton'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import UserAvatar from '@/components/common/UserAvatar.vue'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
@@ -221,8 +219,7 @@ const workspaceStore = useTeamWorkspaceStore()
const {
workspaceName,
isInPersonalWorkspace: isPersonalWorkspace,
isWorkspaceSubscribed,
subscriptionPlan
isWorkspaceSubscribed
} = storeToRefs(workspaceStore)
const { workspaceRole } = useWorkspaceUI()
const workspaceSwitcherPopover = ref<InstanceType<typeof Popover> | null>(null)
@@ -240,24 +237,12 @@ const dialogService = useDialogService()
const { isActiveSubscription } = useSubscription()
const { totalCredits, isLoadingBalance } = useSubscriptionCredits()
const subscriptionDialog = useSubscriptionDialog()
const { t } = useI18n()
const displayedCredits = computed(() =>
isWorkspaceSubscribed.value ? totalCredits.value : '0'
)
// Workspace subscription tier name (not user tier)
const workspaceTierName = computed(() => {
if (!isWorkspaceSubscribed.value) return null
if (!subscriptionPlan.value) return null
// Convert plan to display name
if (subscriptionPlan.value === 'PRO_MONTHLY')
return t('subscription.tiers.pro.name')
if (subscriptionPlan.value === 'PRO_YEARLY')
return t('subscription.tierNameYearly', {
name: t('subscription.tiers.pro.name')
})
return null
const displayedCredits = computed(() => {
const isSubscribed = isPersonalWorkspace.value
? isActiveSubscription.value
: isWorkspaceSubscribed.value
return isSubscribed ? totalCredits.value : '0'
})
const canUpgrade = computed(() => {

View File

@@ -38,13 +38,22 @@
:workspace-name="workspace.name"
/>
<div class="flex min-w-0 flex-1 flex-col items-start gap-1">
<span class="text-sm text-base-foreground">
{{ workspace.name }}
</span>
<span
v-if="workspace.type !== 'personal'"
class="text-sm text-muted-foreground"
>
<div class="flex items-center gap-1.5">
<span class="text-sm text-base-foreground">
{{
workspace.type === 'personal'
? $t('workspaceSwitcher.personal')
: workspace.name
}}
</span>
<span
v-if="getTierLabel(workspace)"
class="text-[10px] font-bold uppercase text-base-background bg-base-foreground px-1 py-0.5 rounded-full"
>
{{ getTierLabel(workspace) }}
</span>
</div>
<span class="text-xs text-muted-foreground">
{{ getRoleLabel(workspace.role) }}
</span>
</div>
@@ -58,8 +67,6 @@
</template>
</template>
<!-- <Divider class="mx-0 my-0" /> -->
<!-- Create workspace button -->
<div class="px-2 py-2">
<div
@@ -107,6 +114,7 @@ import { useI18n } from 'vue-i18n'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import type {
WorkspaceRole,
WorkspaceType
@@ -114,11 +122,15 @@ import type {
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@/utils/tailwindUtil'
type SubscriptionPlan = 'PRO_MONTHLY' | 'PRO_YEARLY' | null
interface AvailableWorkspace {
id: string
name: string
type: WorkspaceType
role: WorkspaceRole
isSubscribed: boolean
subscriptionPlan: SubscriptionPlan
}
const emit = defineEmits<{
@@ -128,6 +140,7 @@ const emit = defineEmits<{
const { t } = useI18n()
const { switchWithConfirmation } = useWorkspaceSwitch()
const { subscriptionTierName: userSubscriptionTierName } = useSubscription()
const workspaceStore = useTeamWorkspaceStore()
const { workspaceId, workspaces, canCreateWorkspace, isFetchingWorkspaces } =
@@ -138,7 +151,9 @@ const availableWorkspaces = computed<AvailableWorkspace[]>(() =>
id: w.id,
name: w.name,
type: w.type,
role: w.role
role: w.role,
isSubscribed: w.isSubscribed,
subscriptionPlan: w.subscriptionPlan
}))
)
@@ -152,6 +167,22 @@ function getRoleLabel(role: AvailableWorkspace['role']): string {
return ''
}
function getTierLabel(workspace: AvailableWorkspace): string | null {
// Personal workspace: use user's subscription tier
if (workspace.type === 'personal') {
return userSubscriptionTierName.value || null
}
// Team workspace: use workspace subscription plan
if (!workspace.isSubscribed || !workspace.subscriptionPlan) return null
if (workspace.subscriptionPlan === 'PRO_MONTHLY')
return t('subscription.tiers.pro.name')
if (workspace.subscriptionPlan === 'PRO_YEARLY')
return t('subscription.tierNameYearly', {
name: t('subscription.tiers.pro.name')
})
return null
}
async function handleSelectWorkspace(workspace: AvailableWorkspace) {
const success = await switchWithConfirmation(workspace.id)
if (success) {

View File

@@ -1,6 +1,7 @@
{
"g": {
"user": "User",
"you": "You",
"currentUser": "Current user",
"empty": "Empty",
"noWorkflowsFound": "No workflows found.",
@@ -43,8 +44,6 @@
"comfy": "Comfy",
"refresh": "Refresh",
"refreshNode": "Refresh Node",
"vitePreloadErrorTitle": "New Version Available",
"vitePreloadErrorMessage": "A new version of the app has been released. Would you like to reload?\nIf not, some parts of the app might not work as expected.\nFeel free to decline and save your progress before reloading.",
"terminal": "Terminal",
"logs": "Logs",
"videoFailedToLoad": "Video failed to load",
@@ -164,6 +163,7 @@
"choose_file_to_upload": "choose file to upload",
"capture": "capture",
"nodes": "Nodes",
"nodesCount": "{count} nodes | {count} node | {count} nodes",
"community": "Community",
"all": "All",
"versionMismatchWarning": "Version Compatibility Warning",
@@ -276,6 +276,7 @@
"1x": "1x",
"2x": "2x",
"beta": "BETA",
"nightly": "NIGHTLY",
"profile": "Profile",
"noItems": "No items"
},
@@ -444,6 +445,8 @@
"Save Image": "Save Image",
"Rename": "Rename",
"RenameWidget": "Rename Widget",
"FavoriteWidget": "Favorite Widget",
"UnfavoriteWidget": "Unfavorite Widget",
"Copy": "Copy",
"Duplicate": "Duplicate",
"Paste": "Paste",
@@ -707,6 +710,8 @@
"noImportedFiles": "No imported files found",
"noGeneratedFiles": "No generated files found",
"generatedAssetsHeader": "Generated assets",
"importedAssetsHeader": "Imported assets",
"activeJobStatus": "Active job: {status}",
"noFilesFoundMessage": "Upload files or generate content to see them here",
"browseTemplates": "Browse example templates",
"openWorkflow": "Open workflow in local file system",
@@ -756,6 +761,7 @@
"sortJobs": "Sort jobs",
"sortBy": "Sort by",
"activeJobs": "{count} active job | {count} active jobs",
"activeJobsShort": "{count} active | {count} active",
"activeJobsSuffix": "active jobs",
"jobQueue": "Job Queue",
"expandCollapsedQueue": "Expand job queue",
@@ -1186,13 +1192,14 @@
"Queue Selected Output Nodes": "Queue Selected Output Nodes",
"Redo": "Redo",
"Refresh Node Definitions": "Refresh Node Definitions",
"Rename": "Rename",
"Save": "Save",
"Save As": "Save As",
"Show Settings Dialog": "Show Settings Dialog",
"Experimental: Enable AssetAPI": "Experimental: Enable AssetAPI",
"Canvas Performance": "Canvas Performance",
"Help Center": "Help Center",
"toggle linear mode": "Toggle Simple Mode",
"Toggle Simple Mode": "Toggle Simple Mode",
"Toggle Queue Panel V2": "Toggle Queue Panel V2",
"Toggle Theme (Dark/Light)": "Toggle Theme (Dark/Light)",
"Undo": "Undo",
@@ -1281,7 +1288,6 @@
"Execution": "Execution",
"PLY": "PLY",
"Workspace": "Workspace",
"General": "General",
"Other": "Other"
},
"serverConfigItems": {
@@ -1433,6 +1439,7 @@
"latent": "latent",
"mask": "mask",
"api node": "api node",
"Bria": "Bria",
"video": "video",
"ByteDance": "ByteDance",
"preprocessors": "preprocessors",
@@ -1483,6 +1490,7 @@
"lotus": "lotus",
"LTXV": "LTXV",
"Luma": "Luma",
"Meshy": "Meshy",
"MiniMax": "MiniMax",
"model_specific": "model_specific",
"Moonvalley Marey": "Moonvalley Marey",
@@ -1512,6 +1520,7 @@
"": "",
"camera": "camera",
"Wan": "Wan",
"WaveSpeed": "WaveSpeed",
"zimage": "zimage"
},
"dataTypes": {
@@ -1552,6 +1561,8 @@
"LUMA_REF": "LUMA_REF",
"MASK": "MASK",
"MESH": "MESH",
"MESHY_RIGGED_TASK_ID": "MESHY_RIGGED_TASK_ID",
"MESHY_TASK_ID": "MESHY_TASK_ID",
"MODEL": "MODEL",
"MODEL_PATCH": "MODEL_PATCH",
"MODEL_TASK_ID": "MODEL_TASK_ID",
@@ -1722,6 +1733,17 @@
"unsupportedFileType": "Unsupported file type (supports .gltf, .glb, .obj, .fbx, .stl)",
"uploadingModel": "Uploading 3D model..."
},
"imageCrop": {
"loading": "Loading...",
"noInputImage": "No input image connected",
"cropPreviewAlt": "Crop preview"
},
"boundingBox": {
"x": "X",
"y": "Y",
"width": "Width",
"height": "Height"
},
"toastMessages": {
"nothingToQueue": "Nothing to queue",
"pleaseSelectOutputNodes": "Please select output nodes",
@@ -2079,6 +2101,10 @@
"creator": "30 min",
"pro": "1 hr",
"founder": "30 min"
},
"billingComingSoon": {
"title": "Coming Soon",
"message": "Team billing is coming soon. You'll be able to subscribe to a plan for your workspace with per-seat pricing. Stay tuned for updates."
}
},
"userSettings": {
@@ -2092,8 +2118,38 @@
"updatePassword": "Update Password"
},
"workspacePanel": {
"invite": "Invite",
"inviteMember": "Invite member",
"inviteLimitReached": "You've reached the maximum of 50 members",
"tabs": {
"planCredits": "Plan & Credits"
"dashboard": "Dashboard",
"planCredits": "Plan & Credits",
"membersCount": "Members ({count})"
},
"dashboard": {
"placeholder": "Dashboard workspace settings"
},
"members": {
"membersCount": "{count}/50 Members",
"pendingInvitesCount": "{count} pending invite | {count} pending invites",
"tabs": {
"active": "Active",
"pendingCount": "Pending ({count})"
},
"columns": {
"inviteDate": "Invite date",
"expiryDate": "Expiry date",
"joinDate": "Join date"
},
"actions": {
"copyLink": "Copy invite link",
"revokeInvite": "Revoke invite",
"removeMember": "Remove member"
},
"noInvites": "No pending invites",
"noMembers": "No members",
"personalWorkspaceMessage": "You can't invite other members to your personal workspace right now. To add members to a workspace,",
"createNewWorkspace": "create a new one."
},
"menu": {
"editWorkspace": "Edit workspace details",
@@ -2116,6 +2172,32 @@
"message": "Any unused credits or unsaved assets will be lost. This action cannot be undone.",
"messageWithName": "Delete \"{name}\"? Any unused credits or unsaved assets will be lost. This action cannot be undone."
},
"removeMemberDialog": {
"title": "Remove this member?",
"message": "This member will be removed from your workspace. Credits they've used will not be refunded.",
"remove": "Remove member",
"success": "Member removed",
"error": "Failed to remove member"
},
"revokeInviteDialog": {
"title": "Uninvite this person?",
"message": "This member won't be able to join your workspace anymore. Their invite link will be invalidated.",
"revoke": "Uninvite"
},
"inviteMemberDialog": {
"title": "Invite a person to this workspace",
"message": "Create a shareable invite link to send to someone",
"placeholder": "Enter the person's email",
"createLink": "Create link",
"linkStep": {
"title": "Send this link to the person",
"message": "Make sure their account uses this email.",
"copyLink": "Copy Link",
"done": "Done"
},
"linkCopied": "Copied",
"linkCopyFailed": "Failed to copy link"
},
"createWorkspaceDialog": {
"title": "Create a new workspace",
"message": "Workspaces let members share a single credits pool. You'll become the owner after creating this.",
@@ -2124,19 +2206,34 @@
"create": "Create"
},
"toast": {
"workspaceCreated": {
"title": "Workspace created",
"message": "Subscribe to a plan, invite teammates, and start collaborating.",
"subscribe": "Subscribe"
},
"workspaceUpdated": {
"title": "Workspace updated",
"message": "Workspace details have been saved."
},
"workspaceDeleted": {
"title": "Workspace deleted",
"message": "The workspace has been permanently deleted."
},
"workspaceLeft": {
"title": "Left workspace",
"message": "You have left the workspace."
},
"failedToUpdateWorkspace": "Failed to update workspace",
"failedToCreateWorkspace": "Failed to create workspace",
"failedToDeleteWorkspace": "Failed to delete workspace",
"failedToLeaveWorkspace": "Failed to leave workspace"
"failedToLeaveWorkspace": "Failed to leave workspace",
"failedToFetchWorkspaces": "Failed to load workspaces"
}
},
"workspaceSwitcher": {
"switchWorkspace": "Switch workspace",
"subscribe": "Subscribe",
"personal": "Personal",
"roleOwner": "Owner",
"roleMember": "Member",
"createWorkspace": "Create new workspace",
@@ -2152,6 +2249,7 @@
"Set Group Nodes to Always": "Set Group Nodes to Always"
},
"widgets": {
"node2only": "Node 2.0 only",
"selectModel": "Select model",
"uploadSelect": {
"placeholder": "Select...",
@@ -2230,6 +2328,7 @@
"renderErrorState": "Render Error State"
},
"cloudOnboarding": {
"skipToCloudApp": "Skip to the cloud app",
"survey": {
"title": "Cloud Survey",
"placeholder": "Survey questions placeholder",
@@ -2508,7 +2607,7 @@
"zoom": "Zoom in",
"moreOptions": "More options",
"seeMoreOutputs": "See more outputs",
"addToWorkflow": "Add to current workflow",
"insertAsNodeInWorkflow": "Insert as node in workflow",
"download": "Download",
"openWorkflow": "Open as workflow in new tab",
"exportWorkflow": "Export workflow",
@@ -2529,11 +2628,23 @@
"downloadSelectedAll": "Download all",
"deleteSelected": "Delete",
"deleteSelectedAll": "Delete all",
"insertAllAssetsAsNodes": "Insert all assets as nodes",
"openWorkflowAll": "Open all workflows",
"exportWorkflowAll": "Export all workflows",
"downloadStarted": "Downloading {count} files...",
"downloadsStarted": "Started downloading {count} file(s)",
"assetsDeletedSuccessfully": "{count} asset(s) deleted successfully",
"failedToDeleteAssets": "Failed to delete selected assets",
"partialDeleteSuccess": "{succeeded} deleted successfully, {failed} failed"
"partialDeleteSuccess": "{succeeded} deleted successfully, {failed} failed",
"nodesAddedToWorkflow": "{count} node(s) added to workflow",
"failedToAddNodes": "Failed to add nodes to workflow",
"partialAddNodesSuccess": "{succeeded} added successfully, {failed} failed",
"workflowsOpened": "{count} workflow(s) opened in new tabs",
"noWorkflowsFound": "No workflow data found in selected assets",
"partialWorkflowsOpened": "{succeeded} workflow(s) opened, {failed} failed",
"workflowsExported": "{count} workflow(s) exported successfully",
"noWorkflowsToExport": "No workflow data found to export",
"partialWorkflowsExported": "{succeeded} exported successfully, {failed} failed"
},
"noJobIdFound": "No job ID found for this asset",
"unsupportedFileType": "Unsupported file type for loader node",
@@ -2599,8 +2710,10 @@
"rightSidePanel": {
"togglePanel": "Toggle properties panel",
"noSelection": "Select a node to see its properties and info.",
"title": "No node(s) selected | 1 node selected | {count} nodes selected",
"workflowOverview": "Workflow Overview",
"title": "No item(s) selected | 1 item selected | {count} items selected",
"parameters": "Parameters",
"nodes": "Nodes",
"info": "Info",
"color": "Node color",
"pinned": "Pinned",
@@ -2610,9 +2723,44 @@
"inputs": "INPUTS",
"inputsNone": "NO INPUTS",
"inputsNoneTooltip": "Node has no inputs",
"advancedInputs": "ADVANCED INPUTS",
"showAdvancedInputsButton": "Show advanced inputs",
"properties": "Properties",
"nodeState": "Node state",
"settings": "Settings"
"settings": "Settings",
"addFavorite": "Favorite",
"removeFavorite": "Unfavorite",
"hideInput": "Hide input",
"showInput": "Show input",
"locateNode": "Locate node on canvas",
"favorites": "FAVORITED INPUTS",
"favoritesNone": "NO FAVORITED INPUTS",
"favoritesNoneTooltip": "Star widgets to quickly access them without selecting nodes",
"globalSettings": {
"title": "Global Settings",
"searchPlaceholder": "Search quick settings...",
"nodes": "NODES",
"canvas": "CANVAS",
"connectionLinks": "CONNECTION LINKS",
"showAdvanced": "Show advanced parameters",
"showAdvancedTooltip": "This is an important setting that when set to TRUE, reveals all advanced parameters for nodes",
"showInfoBadges": "Show info badges",
"showToolbox": "Show toolbox on selection",
"nodes2": "Nodes 2.0",
"gridSpacing": "Grid spacing",
"snapNodesToGrid": "Snap nodes to grid",
"linkShape": "Link shape",
"showConnectedLinks": "Show connected links",
"viewAllSettings": "View all settings"
},
"groupSettings": "Group Settings",
"groups": "Groups",
"favoritesNoneDesc": "Inputs you favorite will show up here",
"noneSearchDesc": "No items match your search",
"nodesNoneDesc": "NO NODES",
"fallbackGroupTitle": "Group",
"fallbackNodeTitle": "Node",
"hideAdvancedInputsButton": "Hide advanced inputs"
},
"help": {
"recentReleases": "Recent releases",
@@ -2638,7 +2786,10 @@
"unsavedChanges": {
"title": "Unsaved Changes",
"message": "You have unsaved changes. Do you want to discard them and switch workspaces?"
}
},
"inviteAccepted": "Invite Accepted",
"addedToWorkspace": "You have been added to {workspaceName}",
"inviteFailed": "Failed to Accept Invite"
},
"workspaceAuth": {
"errors": {
@@ -2648,5 +2799,12 @@
"workspaceNotFound": "Workspace not found",
"tokenExchangeFailed": "Failed to authenticate with workspace: {error}"
}
},
"nightly": {
"badge": {
"label": "Preview Version",
"tooltip": "You are using a nightly version of ComfyUI. Please use the feedback button to share your thoughts about these features."
}
}
}
}

View File

@@ -26,14 +26,16 @@ vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
const mockRemoteConfig = vi.hoisted(() => ({
value: {
team_workspaces_enabled: true
}
}))
const mockTeamWorkspacesEnabled = vi.hoisted(() => ({ value: true }))
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
remoteConfig: mockRemoteConfig
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get teamWorkspacesEnabled() {
return mockTeamWorkspacesEnabled.value
}
}
})
}))
const mockWorkspace = {
@@ -622,11 +624,11 @@ describe('useWorkspaceAuthStore', () => {
describe('feature flag disabled', () => {
beforeEach(() => {
mockRemoteConfig.value.team_workspaces_enabled = false
mockTeamWorkspacesEnabled.value = false
})
afterEach(() => {
mockRemoteConfig.value.team_workspaces_enabled = true
mockTeamWorkspacesEnabled.value = true
})
it('initializeFromSession returns false when flag disabled', () => {

View File

@@ -14,7 +14,7 @@ const mockSubscriptionTier = ref<
const mockIsYearlySubscription = ref(false)
const mockAccessBillingPortal = vi.fn()
const mockReportError = vi.fn()
const mockGetAuthHeader = vi.fn(() =>
const mockGetFirebaseAuthHeader = vi.fn(() =>
Promise.resolve({ Authorization: 'Bearer test-token' })
)
@@ -53,7 +53,7 @@ vi.mock('@/composables/useErrorHandling', () => ({
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => ({
getAuthHeader: mockGetAuthHeader
getFirebaseAuthHeader: mockGetFirebaseAuthHeader
}),
FirebaseAuthStoreError: class extends Error {}
}))

View File

@@ -332,7 +332,7 @@ const tiers: PricingTierConfig[] = [
]
const { n } = useI18n()
const { getAuthHeader } = useFirebaseAuthStore()
const { getFirebaseAuthHeader } = useFirebaseAuthStore()
const { isActiveSubscription, subscriptionTier, isYearlySubscription } =
useSubscription()
const { accessBillingPortal, reportError } = useFirebaseAuthActions()
@@ -406,7 +406,7 @@ const getCreditsDisplay = (tier: PricingTierConfig): number =>
tier.pricing.credits * (currentBillingCycle.value === 'yearly' ? 12 : 1)
const initiateCheckout = async (tierKey: CheckoutTierKey) => {
const authHeader = await getAuthHeader()
const authHeader = await getFirebaseAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}

View File

@@ -68,7 +68,7 @@
<script setup lang="ts">
import TabPanel from 'primevue/tabpanel'
import { defineAsyncComponent } from 'vue'
import { computed, defineAsyncComponent } from 'vue'
import CloudBadge from '@/components/topbar/CloudBadge.vue'
import Button from '@/components/ui/button/Button.vue'
@@ -85,7 +85,9 @@ const SubscriptionPanelContentWorkspace = defineAsyncComponent(
)
const { flags } = useFeatureFlags()
const teamWorkspacesEnabled = isCloud && flags.teamWorkspacesEnabled
const teamWorkspacesEnabled = computed(
() => isCloud && flags.teamWorkspacesEnabled
)
const { buildDocsUrl, docsPaths } = useExternalLink()

View File

@@ -1,10 +1,12 @@
<template>
<div class="grow overflow-auto">
<div class="grow overflow-auto pt-6">
<div class="rounded-2xl border border-interface-stroke p-6">
<div>
<div class="flex items-center justify-between gap-2">
<div
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between md:gap-2"
>
<!-- OWNER Unsubscribed State -->
<template v-if="isOwnerUnsubscribed">
<template v-if="showSubscribePrompt">
<div class="flex flex-col gap-2">
<div class="text-sm font-bold text-text-primary">
{{ $t('subscription.workspaceNotSubscribed') }}
@@ -15,6 +17,7 @@
</div>
<Button
variant="primary"
size="lg"
class="ml-auto rounded-lg px-4 py-2 text-sm font-normal"
@click="handleSubscribeWorkspace"
>
@@ -65,12 +68,14 @@
</div>
</div>
<template
<div
v-if="isActiveSubscription && permissions.canManageSubscription"
class="flex flex-wrap gap-2 md:ml-auto"
>
<Button
size="lg"
variant="secondary"
class="ml-auto rounded-lg px-4 py-2 text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
class="rounded-lg px-4 text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
@click="
async () => {
await authActions.accessBillingPortal()
@@ -80,23 +85,24 @@
{{ $t('subscription.managePayment') }}
</Button>
<Button
size="lg"
variant="primary"
class="rounded-lg px-4 py-2 text-sm font-normal text-text-primary"
class="rounded-lg px-4 text-sm font-normal text-text-primary"
@click="showSubscriptionDialog"
>
{{ $t('subscription.upgradePlan') }}
</Button>
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="muted-textonly"
size="icon"
variant="secondary"
size="lg"
:aria-label="$t('g.moreOptions')"
@click="planMenu?.toggle($event)"
>
<i class="pi pi-ellipsis-h" />
</Button>
<Menu ref="planMenu" :model="planMenuItems" :popup="true" />
</template>
</div>
</template>
</div>
</div>
@@ -260,30 +266,39 @@ import {
} from '@/platform/cloud/subscription/constants/tierPricing'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogService } from '@/services/dialogService'
import { cn } from '@/utils/tailwindUtil'
const authActions = useFirebaseAuthActions()
const workspaceStore = useTeamWorkspaceStore()
const { isWorkspaceSubscribed } = storeToRefs(workspaceStore)
const { isWorkspaceSubscribed, isInPersonalWorkspace } =
storeToRefs(workspaceStore)
const { subscribeWorkspace } = workspaceStore
const { permissions, workspaceRole } = useWorkspaceUI()
const { t, n } = useI18n()
const { showBillingComingSoonDialog } = useDialogService()
// OWNER with unsubscribed workspace - can see subscribe button
const isOwnerUnsubscribed = computed(
() => workspaceRole.value === 'owner' && !isWorkspaceSubscribed.value
)
// Show subscribe prompt to owners without active subscription
const showSubscribePrompt = computed(() => {
if (workspaceRole.value !== 'owner') return false
if (isInPersonalWorkspace.value) return !isActiveSubscription.value
return !isWorkspaceSubscribed.value
})
// MEMBER view - members can't manage subscription, show read-only zero state
const isMemberView = computed(() => !permissions.value.canManageSubscription)
// Show zero state for credits (no real billing data yet)
const showZeroState = computed(
() => isOwnerUnsubscribed.value || isMemberView.value
() => showSubscribePrompt.value || isMemberView.value
)
// Demo: Subscribe workspace to PRO monthly plan
// Subscribe workspace - show billing coming soon dialog for team workspaces
function handleSubscribeWorkspace() {
if (!isInPersonalWorkspace.value) {
showBillingComingSoonDialog()
return
}
subscribeWorkspace('PRO_MONTHLY')
}

View File

@@ -56,7 +56,9 @@ const writeToStorage = (
}
export const hydratePreservedQuery = (namespace: string) => {
if (preservedQueries.has(namespace)) return
if (preservedQueries.has(namespace)) {
return
}
const payload = readFromStorage(namespace)
if (payload) {
preservedQueries.set(namespace, payload)
@@ -77,7 +79,9 @@ export const capturePreservedQuery = (
}
})
if (Object.keys(payload).length === 0) return
if (Object.keys(payload).length === 0) {
return
}
preservedQueries.set(namespace, payload)
writeToStorage(namespace, payload)

View File

@@ -1,3 +1,4 @@
export const PRESERVED_QUERY_NAMESPACES = {
TEMPLATE: 'template'
TEMPLATE: 'template',
INVITE: 'invite'
} as const

View File

@@ -2,25 +2,27 @@
<div
:class="
teamWorkspacesEnabled
? 'flex h-[80vh] w-full overflow-hidden'
? 'flex h-full w-full overflow-auto flex-col md:flex-row'
: 'settings-container'
"
>
<ScrollPanel
:class="
teamWorkspacesEnabled
? 'w-48 shrink-0 p-2 2xl:w-64'
? 'w-full md:w-64 md:min-w-64 md:max-w-64 shrink-0 p-2'
: 'settings-sidebar w-48 shrink-0 p-2 2xl:w-64'
"
>
<SearchBox
v-model:model-value="searchQuery"
class="settings-search-box mb-2 w-full"
:placeholder="$t('g.searchSettings') + '...'"
:debounce-time="128"
autofocus
@search="handleSearch"
/>
<div :class="teamWorkspacesEnabled ? 'px-4' : ''">
<SearchBox
v-model:model-value="searchQuery"
class="settings-search-box mb-2 w-full"
:placeholder="$t('g.searchSettings') + '...'"
:debounce-time="128"
autofocus
@search="handleSearch"
/>
</div>
<Listbox
v-model="activeCategory"
:options="groupedMenuTreeNodes"
@@ -62,7 +64,7 @@
:lazy="true"
:class="
teamWorkspacesEnabled
? 'h-full flex-1 overflow-x-auto'
? 'h-full flex-1 overflow-auto scrollbar-custom'
: 'settings-content h-full w-full'
"
>

View File

@@ -22,6 +22,7 @@ export interface Member {
name: string
email: string
joined_at: string
role: WorkspaceRole
}
interface PaginationInfo {
@@ -110,6 +111,18 @@ async function getAuthHeaderOrThrow() {
return authHeader
}
async function getFirebaseHeaderOrThrow() {
const authHeader = await useFirebaseAuthStore().getFirebaseAuthHeader()
if (!authHeader) {
throw new WorkspaceApiError(
t('toastMessages.userNotAuthenticated'),
401,
'NOT_AUTHENTICATED'
)
}
return authHeader
}
function handleAxiosError(err: unknown): never {
if (axios.isAxiosError(err)) {
const status = err.response?.status
@@ -296,9 +309,10 @@ export const workspaceApi = {
/**
* Accept a workspace invite.
* POST /api/invites/:token/accept
* Uses Firebase auth (user identity) since the user isn't yet a workspace member.
*/
async acceptInvite(token: string): Promise<AcceptInviteResponse> {
const headers = await getAuthHeaderOrThrow()
const headers = await getFirebaseHeaderOrThrow()
try {
const response = await workspaceApiClient.post<AcceptInviteResponse>(
api.apiURL(`/invites/${token}/accept`),

View File

@@ -0,0 +1,232 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useInviteUrlLoader } from './useInviteUrlLoader'
/**
* Unit tests for useInviteUrlLoader composable
*
* Tests the behavior of accepting workspace invites via URL query parameters:
* - ?invite=TOKEN accepts the invite and shows success toast
* - Invalid/missing token is handled gracefully
* - API errors show error toast
* - URL is cleaned up after processing
* - Preserved query is restored after login redirect
*/
const preservedQueryMocks = vi.hoisted(() => ({
clearPreservedQuery: vi.fn(),
hydratePreservedQuery: vi.fn(),
mergePreservedQueryIntoQuery: vi.fn()
}))
vi.mock(
'@/platform/navigation/preservedQueryManager',
() => preservedQueryMocks
)
const mockRouteQuery = vi.hoisted(() => ({
value: {} as Record<string, string>
}))
const mockRouterReplace = vi.hoisted(() => vi.fn())
vi.mock('vue-router', () => ({
useRoute: () => ({
query: mockRouteQuery.value
}),
useRouter: () => ({
replace: mockRouterReplace
})
}))
const mockToastAdd = vi.hoisted(() => vi.fn())
vi.mock('primevue/usetoast', () => ({
useToast: () => ({
add: mockToastAdd
})
}))
vi.mock('vue-i18n', () => ({
createI18n: () => ({
global: {
t: (key: string) => key
}
}),
useI18n: () => ({
t: vi.fn((key: string, params?: Record<string, unknown>) => {
if (key === 'workspace.inviteAccepted') return 'Invite Accepted'
if (key === 'workspace.addedToWorkspace') {
return `You have been added to ${params?.workspaceName}`
}
if (key === 'workspace.inviteFailed') return 'Failed to Accept Invite'
if (key === 'g.unknownError') return 'Unknown error'
return key
})
})
}))
const mockAcceptInvite = vi.hoisted(() => vi.fn())
vi.mock('../stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
acceptInvite: mockAcceptInvite
})
}))
describe('useInviteUrlLoader', () => {
beforeEach(() => {
vi.clearAllMocks()
mockRouteQuery.value = {}
preservedQueryMocks.mergePreservedQueryIntoQuery.mockReturnValue(null)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('loadInviteFromUrl', () => {
it('does nothing when no invite param present', async () => {
mockRouteQuery.value = {}
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
expect(mockAcceptInvite).not.toHaveBeenCalled()
expect(mockToastAdd).not.toHaveBeenCalled()
expect(mockRouterReplace).not.toHaveBeenCalled()
})
it('restores preserved query and processes invite', async () => {
mockRouteQuery.value = {}
preservedQueryMocks.mergePreservedQueryIntoQuery.mockReturnValue({
invite: 'preserved-token'
})
mockAcceptInvite.mockResolvedValue({
workspaceId: 'ws-123',
workspaceName: 'Test Workspace'
})
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
expect(preservedQueryMocks.hydratePreservedQuery).toHaveBeenCalledWith(
'invite'
)
expect(mockRouterReplace).toHaveBeenCalledWith({
query: { invite: 'preserved-token' }
})
expect(mockAcceptInvite).toHaveBeenCalledWith('preserved-token')
})
it('accepts invite and shows success toast on success', async () => {
mockRouteQuery.value = { invite: 'valid-token' }
mockAcceptInvite.mockResolvedValue({
workspaceId: 'ws-123',
workspaceName: 'Test Workspace'
})
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
expect(mockAcceptInvite).toHaveBeenCalledWith('valid-token')
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'success',
summary: 'Invite Accepted',
detail: 'You have been added to Test Workspace',
life: 5000
})
})
it('shows error toast when invite acceptance fails', async () => {
mockRouteQuery.value = { invite: 'invalid-token' }
mockAcceptInvite.mockRejectedValue(new Error('Invalid invite'))
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
expect(mockAcceptInvite).toHaveBeenCalledWith('invalid-token')
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'Failed to Accept Invite',
detail: 'Invalid invite',
life: 5000
})
})
it('cleans up URL after processing invite', async () => {
mockRouteQuery.value = { invite: 'valid-token', other: 'param' }
mockAcceptInvite.mockResolvedValue({
workspaceId: 'ws-123',
workspaceName: 'Test Workspace'
})
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
// Should replace with query without invite param
expect(mockRouterReplace).toHaveBeenCalledWith({
query: { other: 'param' }
})
})
it('clears preserved query after processing', async () => {
mockRouteQuery.value = { invite: 'valid-token' }
mockAcceptInvite.mockResolvedValue({
workspaceId: 'ws-123',
workspaceName: 'Test Workspace'
})
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
'invite'
)
})
it('clears preserved query even on error', async () => {
mockRouteQuery.value = { invite: 'invalid-token' }
mockAcceptInvite.mockRejectedValue(new Error('Invalid invite'))
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
'invite'
)
})
it('sends any token format to backend for validation', async () => {
mockRouteQuery.value = { invite: 'any-token-format==' }
mockAcceptInvite.mockRejectedValue(new Error('Invalid token'))
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
// Token is sent to backend, which validates and rejects
expect(mockAcceptInvite).toHaveBeenCalledWith('any-token-format==')
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'Failed to Accept Invite',
detail: 'Invalid token',
life: 5000
})
})
it('ignores empty invite param', async () => {
mockRouteQuery.value = { invite: '' }
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
expect(mockAcceptInvite).not.toHaveBeenCalled()
})
it('ignores non-string invite param', async () => {
mockRouteQuery.value = { invite: ['array', 'value'] as unknown as string }
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
expect(mockAcceptInvite).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,107 @@
import { useToast } from 'primevue/usetoast'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import {
clearPreservedQuery,
hydratePreservedQuery,
mergePreservedQueryIntoQuery
} from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useTeamWorkspaceStore } from '../stores/teamWorkspaceStore'
/**
* Composable for loading workspace invites from URL query parameters
*
* Supports URLs like:
* - /?invite=TOKEN (accepts workspace invite)
*
* The invite token is preserved through login redirects via the
* preserved query system (sessionStorage), following the same pattern
* as the template URL loader.
*/
export function useInviteUrlLoader() {
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const toast = useToast()
const workspaceStore = useTeamWorkspaceStore()
const INVITE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.INVITE
/**
* Hydrates preserved query from sessionStorage and merges into route.
* This restores the invite token after login redirects.
*/
const ensureInviteQueryFromIntent = async () => {
hydratePreservedQuery(INVITE_NAMESPACE)
const mergedQuery = mergePreservedQueryIntoQuery(
INVITE_NAMESPACE,
route.query
)
if (mergedQuery) {
await router.replace({ query: mergedQuery })
}
return mergedQuery ?? route.query
}
/**
* Removes invite parameter from URL using Vue Router
*/
const cleanupUrlParams = () => {
const newQuery = { ...route.query }
delete newQuery.invite
void router.replace({ query: newQuery })
}
/**
* Loads and accepts workspace invite from URL query parameters if present.
* Handles errors internally and shows appropriate user feedback.
*
* Flow:
* 1. Restore preserved query (for post-login redirect)
* 2. Check for invite token in route.query
* 3. Accept the invite via API (backend validates token)
* 4. Show toast notification
* 5. Clean up URL and preserved query
*/
const loadInviteFromUrl = async () => {
// Restore preserved query from sessionStorage (handles login redirect case)
const query = await ensureInviteQueryFromIntent()
const inviteParam = query.invite
if (!inviteParam || typeof inviteParam !== 'string') {
return
}
try {
const result = await workspaceStore.acceptInvite(inviteParam)
toast.add({
severity: 'success',
summary: t('workspace.inviteAccepted'),
detail: t(
'workspace.addedToWorkspace',
{ workspaceName: result.workspaceName },
{ escapeParameter: false }
),
life: 5000
})
} catch (error) {
toast.add({
severity: 'error',
summary: t('workspace.inviteFailed'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
})
} finally {
cleanupUrlParams()
clearPreservedQuery(INVITE_NAMESPACE)
}
}
return {
loadInviteFromUrl
}
}

View File

@@ -6,6 +6,11 @@ import { useTeamWorkspaceStore } from '../stores/teamWorkspaceStore'
/** Permission flags for workspace actions */
interface WorkspacePermissions {
canViewOtherMembers: boolean
canViewPendingInvites: boolean
canInviteMembers: boolean
canManageInvites: boolean
canRemoveMembers: boolean
canLeaveWorkspace: boolean
canAccessWorkspaceMenu: boolean
canManageSubscription: boolean
@@ -13,6 +18,14 @@ interface WorkspacePermissions {
/** UI configuration for workspace role */
interface WorkspaceUIConfig {
showMembersList: boolean
showPendingTab: boolean
showSearch: boolean
showDateColumn: boolean
showRoleBadge: boolean
membersGridCols: string
pendingGridCols: string
headerGridCols: string
showEditWorkspaceMenuItem: boolean
workspaceMenuAction: 'leave' | 'delete' | null
workspaceMenuDisabledTooltip: string | null
@@ -24,6 +37,11 @@ function getPermissions(
): WorkspacePermissions {
if (type === 'personal') {
return {
canViewOtherMembers: false,
canViewPendingInvites: false,
canInviteMembers: false,
canManageInvites: false,
canRemoveMembers: false,
canLeaveWorkspace: false,
canAccessWorkspaceMenu: false,
canManageSubscription: true
@@ -32,6 +50,11 @@ function getPermissions(
if (role === 'owner') {
return {
canViewOtherMembers: true,
canViewPendingInvites: true,
canInviteMembers: true,
canManageInvites: true,
canRemoveMembers: true,
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: true
@@ -40,6 +63,11 @@ function getPermissions(
// member role
return {
canViewOtherMembers: true,
canViewPendingInvites: false,
canInviteMembers: false,
canManageInvites: false,
canRemoveMembers: false,
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: false
@@ -52,6 +80,14 @@ function getUIConfig(
): WorkspaceUIConfig {
if (type === 'personal') {
return {
showMembersList: false,
showPendingTab: false,
showSearch: false,
showDateColumn: false,
showRoleBadge: false,
membersGridCols: 'grid-cols-1',
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
headerGridCols: 'grid-cols-1',
showEditWorkspaceMenuItem: false,
workspaceMenuAction: null,
workspaceMenuDisabledTooltip: null
@@ -60,6 +96,14 @@ function getUIConfig(
if (role === 'owner') {
return {
showMembersList: true,
showPendingTab: true,
showSearch: true,
showDateColumn: true,
showRoleBadge: 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:
@@ -69,6 +113,14 @@ function getUIConfig(
// member role
return {
showMembersList: true,
showPendingTab: false,
showSearch: true,
showDateColumn: true,
showRoleBadge: true,
membersGridCols: 'grid-cols-[1fr_auto]',
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
headerGridCols: 'grid-cols-[1fr_auto]',
showEditWorkspaceMenuItem: false,
workspaceMenuAction: 'leave',
workspaceMenuDisabledTooltip: null

View File

@@ -2,6 +2,8 @@ import { defineStore } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { WORKSPACE_STORAGE_KEYS } from '@/platform/auth/workspace/workspaceConstants'
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useWorkspaceAuthStore } from '@/stores/workspaceAuthStore'
import type {
@@ -12,14 +14,15 @@ import type {
} from '../api/workspaceApi'
import { workspaceApi } from '../api/workspaceApi'
interface WorkspaceMember {
export interface WorkspaceMember {
id: string
name: string
email: string
joinDate: Date
role: 'owner' | 'member'
}
interface PendingInvite {
export interface PendingInvite {
id: string
email: string
token: string
@@ -43,7 +46,8 @@ function mapApiMemberToWorkspaceMember(member: Member): WorkspaceMember {
id: member.id,
name: member.name,
email: member.email,
joinDate: new Date(member.joined_at)
joinDate: new Date(member.joined_at),
role: member.role
}
}
@@ -60,7 +64,8 @@ function mapApiInviteToPendingInvite(invite: ApiPendingInvite): PendingInvite {
function createWorkspaceState(workspace: WorkspaceWithRole): WorkspaceState {
return {
...workspace,
isSubscribed: false,
// Personal workspaces use user-scoped subscription from useSubscription()
isSubscribed: workspace.type === 'personal',
subscriptionPlan: null,
members: [],
pendingInvites: []
@@ -367,6 +372,9 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
// Clear context and switch to new workspace
workspaceAuthStore.clearWorkspaceContext()
// Clear any preserved invite query to prevent stale invites from being
// processed after the reload (prevents owner adding themselves as member)
clearPreservedQuery(PRESERVED_QUERY_NAMESPACES.INVITE)
setLastWorkspaceId(newWorkspace.id)
window.location.reload()

View File

@@ -86,6 +86,10 @@ installPreservedQueryTracker(router, [
{
namespace: PRESERVED_QUERY_NAMESPACES.TEMPLATE,
keys: ['template', 'source', 'mode']
},
{
namespace: PRESERVED_QUERY_NAMESPACES.INVITE,
keys: ['invite']
}
])

View File

@@ -42,6 +42,7 @@ export type ConfirmationDialogType =
| 'delete'
| 'dirtyClose'
| 'reinstall'
| 'info'
export const useDialogService = () => {
const dialogStore = useDialogStore()
@@ -578,6 +579,62 @@ export const useDialogService = () => {
})
}
async function showRemoveMemberDialog(memberId: string) {
const { default: component } =
await import('@/components/dialog/content/workspace/RemoveMemberDialogContent.vue')
return dialogStore.showDialog({
key: 'remove-member',
component,
props: { memberId },
dialogComponentProps: workspaceDialogPt
})
}
async function showInviteMemberDialog() {
const { default: component } =
await import('@/components/dialog/content/workspace/InviteMemberDialogContent.vue')
return dialogStore.showDialog({
key: 'invite-member',
component,
dialogComponentProps: {
...workspaceDialogPt,
pt: {
...workspaceDialogPt.pt,
root: { class: 'rounded-2xl max-w-[512px] w-full' }
}
}
})
}
async function showRevokeInviteDialog(inviteId: string) {
const { default: component } =
await import('@/components/dialog/content/workspace/RevokeInviteDialogContent.vue')
return dialogStore.showDialog({
key: 'revoke-invite',
component,
props: { inviteId },
dialogComponentProps: workspaceDialogPt
})
}
function showBillingComingSoonDialog() {
return dialogStore.showDialog({
key: 'billing-coming-soon',
title: t('subscription.billingComingSoon.title'),
component: ConfirmationDialogContent,
props: {
message: t('subscription.billingComingSoon.message'),
type: 'info' as ConfirmationDialogType,
onConfirm: () => {}
},
dialogComponentProps: {
pt: {
root: { class: 'max-w-[360px]' }
}
}
})
}
return {
showLoadWorkflowWarning,
showMissingModelsWarning,
@@ -599,6 +656,10 @@ export const useDialogService = () => {
showDeleteWorkspaceDialog,
showCreateWorkspaceDialog,
showLeaveWorkspaceDialog,
showEditWorkspaceDialog
showEditWorkspaceDialog,
showRemoveMemberDialog,
showRevokeInviteDialog,
showInviteMemberDialog,
showBillingComingSoonDialog
}
}

View File

@@ -8,11 +8,11 @@ import {
TOKEN_REFRESH_BUFFER_MS,
WORKSPACE_STORAGE_KEYS
} from '@/platform/auth/workspace/workspaceConstants'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { api } from '@/scripts/api'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { AuthHeader } from '@/types/authTypes'
import type { WorkspaceWithRole } from '@/platform/auth/workspace/workspaceTypes'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
const WorkspaceWithRoleSchema = z.object({
id: z.string(),
@@ -44,6 +44,8 @@ export class WorkspaceAuthError extends Error {
}
export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
const { flags } = useFeatureFlags()
// State
const currentWorkspace = shallowRef<WorkspaceWithRole | null>(null)
const workspaceToken = ref<string | null>(null)
@@ -120,7 +122,7 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
}
function initializeFromSession(): boolean {
if (!remoteConfig.value.team_workspaces_enabled) {
if (!flags.teamWorkspacesEnabled) {
return false
}
@@ -164,7 +166,7 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
}
async function switchWorkspace(workspaceId: string): Promise<void> {
if (!remoteConfig.value.team_workspaces_enabled) {
if (!flags.teamWorkspacesEnabled) {
return
}