feat: implemented workspace flow

This commit is contained in:
--list
2026-01-13 23:32:18 -08:00
parent a89a48d11e
commit e419a76b5e
22 changed files with 2322 additions and 198 deletions

View File

@@ -0,0 +1,43 @@
<template>
<div
class="flex size-6 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)'
}"
>
{{ letter }}
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const { workspaceName } = defineProps<{
workspaceName: string
}>()
const letter = computed(() => workspaceName.charAt(0).toUpperCase())
const gradient = computed(() => {
const seed = letter.value.charCodeAt(0)
function mulberry32(a: number) {
return function () {
let t = (a += 0x6d2b79f5)
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
const rand = mulberry32(seed)
const hue1 = Math.floor(rand() * 360)
const hue2 = (hue1 + 40 + Math.floor(rand() * 80)) % 360
const sat = 65 + Math.floor(rand() * 20)
const light = 55 + Math.floor(rand() * 15)
return `linear-gradient(135deg, hsl(${hue1}, ${sat}%, ${light}%), hsl(${hue2}, ${sat}%, ${light}%))`
})
</script>

View File

@@ -4,7 +4,10 @@
v-for="item in dialogStore.dialogStack"
:key="item.key"
v-model:visible="item.visible"
class="global-dialog"
:class="[
'global-dialog',
item.key === 'global-settings' ? 'settings-dialog' : ''
]"
v-bind="item.dialogComponentProps"
:pt="item.dialogComponentProps.pt"
:aria-labelledby="item.key"

View File

@@ -0,0 +1,470 @@
<template>
<div
class="flex size-full flex-col gap-2 rounded-2xl border border-border-default 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'">
<!-- Current user (always pinned at top, no date) -->
<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
v-if="isPersonalWorkspace"
class="text-muted-foreground"
>
({{ $t('g.you') }})
</span>
</span>
<div
v-if="uiConfig.showRoleBadge"
class="py-0.5 px-1.5 text-xs bg-background-muted"
>
{{ workspaceRole }}
</div>
</div>
<span class="text-sm text-muted-foreground">
{{ userEmail }}
</span>
</div>
</div>
<!-- Empty cell for grid alignment (no date for current user) -->
<span v-if="uiConfig.showDateColumn" />
<!-- Empty cell for action column (can't remove yourself) -->
<span v-if="permissions.canRemoveMembers" />
</div>
<!-- Other members (sorted) -->
<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">
<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">
{{ member.name.charAt(0).toUpperCase() }}
</span>
</div>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<span class="text-sm text-base-foreground">
{{ member.name }}
</span>
<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) -->
<div
v-if="permissions.canRemoveMembers"
class="flex items-center justify-end"
>
<Button
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>
<!-- 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">
{{ invite.name.charAt(0).toUpperCase() }}
</span>
</div>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<span class="text-sm text-base-foreground">
{{ invite.name }}
</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>
</template>
<script setup lang="ts">
import Menu from 'primevue/menu'
import { computed, onMounted, ref, watch } 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 type {
PendingInvite,
WorkspaceMember
} from '@/platform/workspace/composables/useWorkspace'
import { useWorkspace } from '@/platform/workspace/composables/useWorkspace'
import { useDialogService } from '@/services/dialogService'
import { cn } from '@/utils/tailwindUtil'
const { d, t } = useI18n()
const { userPhotoUrl, userEmail, userDisplayName } = useCurrentUser()
const {
showRemoveMemberDialog,
showRevokeInviteDialog,
showCreateWorkspaceDialog
} = useDialogService()
const {
members,
pendingInvites,
fetchMembers,
fetchPendingInvites,
copyInviteLink,
revokeInvite,
isPersonalWorkspace,
permissions,
uiConfig,
workspaceRole
} = useWorkspace()
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)
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)
}
async function refreshData() {
await Promise.all([fetchMembers(), fetchPendingInvites()])
}
onMounted(() => {
void refreshData()
})
watch(workspaceRole, () => {
// Reset to active view if pending tab is not available for this role
if (!uiConfig.value.showPendingTab) {
activeView.value = 'active'
}
void refreshData()
})
// Other members (sorted, excluding current user)
const filteredMembers = computed(() => {
let result = members.value.filter(
(member) => member.email.toLowerCase() !== userEmail.value?.toLowerCase()
)
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) => {
const aValue = a.joinDate.getTime()
const bValue = b.joinDate.getTime()
return sortDirection.value === 'asc' ? aValue - bValue : bValue - aValue
})
return result
})
const filteredPendingInvites = computed(() => {
let result = [...pendingInvites.value]
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter(
(invite) =>
invite.name.toLowerCase().includes(query) ||
invite.email.toLowerCase().includes(query)
)
}
const field = sortField.value as 'inviteDate' | 'expiryDate'
result.sort((a, b) => {
const aValue = a[field].getTime()
const bValue = b[field].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' })
}
function handleCopyInviteLink(invite: PendingInvite) {
copyInviteLink(invite.id)
}
function handleRevokeInvite(invite: PendingInvite) {
showRevokeInviteDialog(() => {
revokeInvite(invite.id)
})
}
function handleCreateWorkspace() {
showCreateWorkspaceDialog(() => {
// TODO: Implement actual create workspace API call
})
}
function handleRemoveMember(_member: WorkspaceMember) {
showRemoveMemberDialog(() => {
// TODO: Implement actual remove member API call
})
}
</script>

View File

@@ -2,12 +2,6 @@
<TabPanel value="Workspace" class="h-full">
<WorkspacePanelContent />
</TabPanel>
<TabPanel value="WorkspacePlan" class="h-full">
<WorkspacePanelContent default-tab="plan" />
</TabPanel>
<TabPanel value="WorkspaceMembers" class="h-full">
<WorkspacePanelContent default-tab="members" />
</TabPanel>
</template>
<script setup lang="ts">

View File

@@ -1,20 +1,83 @@
<template>
<div class="flex h-full flex-col">
<div class="flex h-full w-full flex-col">
<div class="pb-8 flex items-center gap-4">
<WorkspaceProfilePic
class="size-12 !text-3xl"
:workspace-name="workspaceName"
/>
<h1 class="text-3xl text-base-foreground">
{{ workspaceName }}
</h1>
</div>
<Tabs :value="activeTab" @update:value="setActiveTab">
<TabList>
<Tab value="dashboard">{{ $t('workspacePanel.tabs.dashboard') }}</Tab>
<Tab value="plan">{{ $t('workspacePanel.tabs.planCredits') }}</Tab>
<Tab value="members">{{ $t('workspacePanel.tabs.members') }}</Tab>
</TabList>
<div class="flex w-full items-center">
<TabList class="w-full">
<Tab value="plan">{{ $t('workspacePanel.tabs.planCredits') }}</Tab>
<Tab value="members">{{
$t('workspacePanel.tabs.membersCount', { count: 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"
:aria-label="$t('g.moreOptions')"
@click="menu?.toggle($event)"
>
<i class="pi pi-ellipsis-h" />
</Button>
<Menu ref="menu" :model="menuItems" :popup="true">
<template #item="{ item }">
<div
v-tooltip="
item.disabled && deleteTooltip
? { value: deleteTooltip, showDelay: 0 }
: null
"
:class="[
'flex items-center gap-2 px-3 py-2',
item.class,
item.disabled ? 'pointer-events-auto' : ''
]"
@click="
item.command?.({
originalEvent: $event,
item
})
"
>
<i :class="item.icon" />
<span>{{ item.label }}</span>
</div>
</template>
</Menu>
</template>
</div>
<TabPanels>
<TabPanel value="dashboard">
<div class="p-4">{{ $t('workspacePanel.dashboard.placeholder') }}</div>
</TabPanel>
<TabPanel value="plan">
<SubscriptionPanelContent />
</TabPanel>
<TabPanel value="members">
<div class="p-4">{{ $t('workspacePanel.members.placeholder') }}</div>
<MembersPanelContent :key="workspaceRole" />
</TabPanel>
</TabPanels>
</Tabs>
@@ -22,23 +85,115 @@
</template>
<script setup lang="ts">
import Menu from 'primevue/menu'
import Tab from 'primevue/tab'
import TabList from 'primevue/tablist'
import TabPanel from 'primevue/tabpanel'
import TabPanels from 'primevue/tabpanels'
import Tabs from 'primevue/tabs'
import { onMounted } from 'vue'
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/SubscriptionPanelContent.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useWorkspace } from '@/platform/workspace/composables/useWorkspace'
import { useDialogService } from '@/services/dialogService'
const { defaultTab = 'dashboard' } = defineProps<{
const { defaultTab = 'plan' } = defineProps<{
defaultTab?: string
}>()
const { activeTab, setActiveTab } = useWorkspace()
const { t } = useI18n()
const {
showLeaveWorkspaceDialog,
showDeleteWorkspaceDialog,
showInviteMemberDialog
} = useDialogService()
const { isActiveSubscription } = useSubscription()
const {
activeTab,
setActiveTab,
workspaceName,
workspaceRole,
members,
fetchMembers,
fetchPendingInvites,
permissions,
uiConfig,
isInviteLimitReached
} = useWorkspace()
const menu = ref<InstanceType<typeof Menu> | null>(null)
function handleLeaveWorkspace() {
showLeaveWorkspaceDialog(() => {
// TODO: Implement actual leave workspace API call
})
}
function handleDeleteWorkspace() {
showDeleteWorkspaceDialog(() => {
// TODO: Implement actual delete workspace API call
})
}
const isDeleteDisabled = computed(
() =>
uiConfig.value.workspaceMenuAction === 'delete' &&
isActiveSubscription.value
)
const deleteTooltip = computed(() => {
if (!isDeleteDisabled.value) return null
const tooltipKey = uiConfig.value.workspaceMenuDisabledTooltip
return tooltipKey ? t(tooltipKey) : null
})
const inviteTooltip = computed(() => {
if (!isInviteLimitReached.value) return null
return t('workspacePanel.inviteLimitReached')
})
function handleInviteMember() {
if (isInviteLimitReached.value) return
showInviteMemberDialog((_email: string) => {
// TODO: Implement actual invite member API call
})
}
const menuItems = computed(() => {
const action = uiConfig.value.workspaceMenuAction
if (!action) return []
if (action === 'delete') {
return [
{
label: t('workspacePanel.menu.deleteWorkspace'),
icon: 'pi pi-trash',
class: isDeleteDisabled.value
? 'text-danger/50 cursor-not-allowed'
: 'text-danger',
disabled: isDeleteDisabled.value,
command: isDeleteDisabled.value ? undefined : handleDeleteWorkspace
}
]
}
return [
{
label: t('workspacePanel.menu.leaveWorkspace'),
icon: 'pi pi-sign-out',
command: handleLeaveWorkspace
}
]
})
onMounted(() => {
setActiveTab(defaultTab)
fetchMembers()
fetchPendingInvites()
})
</script>

View File

@@ -1,16 +1,17 @@
<template>
<div class="flex items-center gap-2">
<UserAvatar class="size-6" :photo-url="userPhotoUrl" />
<WorkspaceProfilePic
class="size-6 text-xs"
:workspace-name="workspaceName"
/>
<span>{{ workspaceName ?? 'Personal' }}</span>
<span>{{ workspaceName }}</span>
</div>
</template>
<script setup lang="ts">
import UserAvatar from '@/components/common/UserAvatar.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import { useWorkspace } from '@/platform/workspace/composables/useWorkspace'
const { userPhotoUrl } = useCurrentUser()
const { workspaceName } = useWorkspace()
</script>

View File

@@ -0,0 +1,100 @@
<template>
<div
class="flex w-full max-w-[400px] 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.createWorkspaceDialog.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="flex flex-col gap-4 px-4 py-4">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.createWorkspaceDialog.message') }}
</p>
<div class="flex flex-col gap-2">
<label class="text-sm text-base-foreground">
{{ $t('workspacePanel.createWorkspaceDialog.nameLabel') }}
</label>
<input
v-model="workspaceName"
type="text"
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.createWorkspaceDialog.namePlaceholder')
"
@keydown.enter="isValidName && onCreate()"
/>
</div>
</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="primary"
size="lg"
:loading
:disabled="!isValidName"
@click="onCreate"
>
{{ $t('workspacePanel.createWorkspaceDialog.create') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useDialogStore } from '@/stores/dialogStore'
const { onConfirm } = defineProps<{
onConfirm: (name: string) => void | Promise<void>
}>()
const dialogStore = useDialogStore()
const toast = useToast()
const loading = ref(false)
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\-_]*$/
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
})
function onCancel() {
dialogStore.closeDialog({ key: 'create-workspace' })
}
async function onCreate() {
if (!isValidName.value) return
loading.value = true
try {
await onConfirm(workspaceName.value.trim())
dialogStore.closeDialog({ key: 'create-workspace' })
toast.add({
group: 'workspace-created'
})
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,66 @@
<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.deleteDialog.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.deleteDialog.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="onDelete">
{{ $t('g.delete') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useDialogStore } from '@/stores/dialogStore'
const { onConfirm } = defineProps<{
onConfirm: () => void | Promise<void>
}>()
const dialogStore = useDialogStore()
const loading = ref(false)
function onCancel() {
dialogStore.closeDialog({ key: 'delete-workspace' })
}
async function onDelete() {
loading.value = true
try {
await onConfirm()
dialogStore.closeDialog({ key: 'delete-workspace' })
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,178 @@
<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 Button from '@/components/ui/button/Button.vue'
import { useWorkspace } from '@/platform/workspace/composables/useWorkspace'
import { useDialogStore } from '@/stores/dialogStore'
const { onConfirm } = defineProps<{
onConfirm: (email: string) => void | Promise<void>
}>()
const dialogStore = useDialogStore()
const toast = useToast()
const { createInviteLink } = useWorkspace()
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 createInviteLink(email.value)
step.value = 'link'
await onConfirm(email.value)
} finally {
loading.value = false
}
}
async function onCopyLink() {
try {
await navigator.clipboard.writeText(generatedLink.value)
toast.add({
severity: 'success',
summary: 'Copied',
life: 2000
})
} catch {
toast.add({
severity: 'error',
summary: 'Failed to copy link',
life: 3000
})
}
}
function onSelectLink(event: Event) {
const input = event.target as HTMLInputElement
input.select()
}
</script>

View File

@@ -0,0 +1,66 @@
<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.leaveDialog.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.leaveDialog.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="onLeave">
{{ $t('workspacePanel.leaveDialog.leave') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useDialogStore } from '@/stores/dialogStore'
const { onConfirm } = defineProps<{
onConfirm: () => void | Promise<void>
}>()
const dialogStore = useDialogStore()
const loading = ref(false)
function onCancel() {
dialogStore.closeDialog({ key: 'leave-workspace' })
}
async function onLeave() {
loading.value = true
try {
await onConfirm()
dialogStore.closeDialog({ key: 'leave-workspace' })
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,66 @@
<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 { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useDialogStore } from '@/stores/dialogStore'
const { onConfirm } = defineProps<{
onConfirm: () => void | Promise<void>
}>()
const dialogStore = useDialogStore()
const loading = ref(false)
function onCancel() {
dialogStore.closeDialog({ key: 'remove-member' })
}
async function onRemove() {
loading.value = true
try {
await onConfirm()
dialogStore.closeDialog({ key: 'remove-member' })
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,66 @@
<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 { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useDialogStore } from '@/stores/dialogStore'
const { onConfirm } = defineProps<{
onConfirm: () => void | Promise<void>
}>()
const dialogStore = useDialogStore()
const loading = ref(false)
function onCancel() {
dialogStore.closeDialog({ key: 'revoke-invite' })
}
async function onRevoke() {
loading.value = true
try {
await onConfirm()
dialogStore.closeDialog({ key: 'revoke-invite' })
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,44 @@
<template>
<Toast group="workspace-created">
<template #message>
<div class="flex w-full flex-col gap-3">
<div class="flex flex-col gap-1">
<span class="text-base font-semibold text-base-foreground">
{{ $t('workspacePanel.toast.workspaceCreated.title') }}
</span>
<span class="text-sm text-muted-foreground">
{{ $t('workspacePanel.toast.workspaceCreated.message') }}
</span>
</div>
<div class="flex items-center justify-end gap-2">
<Button variant="muted-textonly" size="sm" @click="handleDismiss">
{{ $t('g.dismiss') }}
</Button>
<Button variant="primary" size="sm" @click="handleSubscribe">
{{ $t('workspacePanel.toast.workspaceCreated.subscribe') }}
</Button>
</div>
</div>
</template>
</Toast>
</template>
<script setup lang="ts">
import Toast from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import Button from '@/components/ui/button/Button.vue'
import { useDialogService } from '@/services/dialogService'
const toast = useToast()
const dialogService = useDialogService()
function handleDismiss() {
toast.removeGroup('workspace-created')
}
function handleSubscribe() {
toast.removeGroup('workspace-created')
dialogService.showSettingsDialog('workspace')
}
</script>

View File

@@ -21,114 +21,197 @@
<p v-if="userEmail" class="my-0 truncate text-sm text-muted">
{{ userEmail }}
</p>
<span
<!-- <span
v-if="subscriptionTierName"
class="my-0 text-xs text-foreground bg-secondary-background-hover rounded-full uppercase px-2 py-0.5 font-bold mt-2"
>
{{ subscriptionTierName }}
</span>
</span> -->
</div>
<!-- Credits Section -->
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-amber-400 text-sm" />
<Skeleton
v-if="authStore.isFetchingBalance"
width="4rem"
height="1.25rem"
class="w-full"
/>
<span v-else class="text-base font-semibold text-base-foreground">{{
formattedBalance
}}</span>
<i
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
class="icon-[lucide--circle-help] cursor-help text-base text-muted-foreground mr-auto"
/>
<Button
variant="secondary"
size="sm"
class="text-base-foreground"
data-testid="add-credits-button"
@click="handleTopUp"
>
{{ $t('subscription.addCredits') }}
</Button>
</div>
<div v-else class="flex justify-center px-4">
<SubscribeButton
:fluid="false"
:label="$t('subscription.subscribeToComfyCloud')"
size="sm"
variant="gradient"
@subscribed="handleSubscribed"
/>
</div>
<Divider class="my-2 mx-0" />
<!-- Workspace Selector -->
<div
v-if="isActiveSubscription"
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
data-testid="partner-nodes-menu-item"
@click="handleOpenPartnerNodesInfo"
class="flex cursor-pointer items-center justify-between rounded-lg px-4 py-2 hover:bg-secondary-background-hover"
@click="toggleWorkspaceSwitcher"
>
<i class="icon-[lucide--tag] text-muted-foreground text-sm" />
<span class="text-sm text-base-foreground flex-1">{{
$t('subscription.partnerNodesCredits')
}}</span>
<div class="flex min-w-0 flex-1 items-center gap-2">
<WorkspaceProfilePic
class="size-6 shrink-0 text-xs"
:workspace-name="workspaceName"
/>
<span class="truncate text-sm text-base-foreground">{{
workspaceName
}}</span>
<div
v-if="subscriptionTierName"
class="shrink-0 rounded bg-secondary-background-hover px-1.5 py-0.5 text-xs"
>
{{ subscriptionTierName }}
</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>
<Popover
ref="workspaceSwitcherPopover"
append-to="body"
:pt="{
content: {
class: 'p-0'
}
}"
>
<WorkspaceSwitcherPopover
@select="workspaceSwitcherPopover?.hide()"
@create="handleCreateWorkspace"
/>
</Popover>
<!-- Credits Section (PERSONAL and OWNER only) -->
<template v-if="showCreditsSection">
<!-- Subscribed: Show balance + Add credits -->
<div
v-if="isActiveSubscription && isWorkspaceSubscribed"
class="flex items-center gap-2 px-4 py-2"
>
<i class="icon-[lucide--component] text-sm text-amber-400" />
<Skeleton
v-if="authStore.isFetchingBalance"
width="4rem"
height="1.25rem"
class="w-full"
/>
<span v-else class="text-base font-semibold text-base-foreground">{{
formattedBalance
}}</span>
<i
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
class="icon-[lucide--circle-help] mr-auto cursor-help text-base text-muted-foreground"
/>
<Button
variant="secondary"
size="sm"
class="text-base-foreground"
data-testid="add-credits-button"
@click="handleTopUp"
>
{{ $t('subscription.addCredits') }}
</Button>
</div>
<!-- OWNER unsubscribed: Show Subscribe button (primary) -->
<div
v-else-if="workspaceRole === 'OWNER' && !isWorkspaceSubscribed"
class="flex justify-center px-4 py-2"
>
<Button
variant="primary"
size="sm"
class="w-full"
data-testid="subscribe-button"
@click="handleOpenPlansAndPricing"
>
{{ $t('subscription.subscribeNow') }}
</Button>
</div>
<!-- PERSONAL unsubscribed: Show gradient SubscribeButton -->
<div v-else class="flex justify-center px-4">
<SubscribeButton
:fluid="false"
:label="$t('subscription.subscribeToComfyCloud')"
size="sm"
variant="gradient"
@subscribed="handleSubscribed"
/>
</div>
<Divider class="mx-0 my-2" />
</template>
<!-- Plans & Pricing (PERSONAL and OWNER only) -->
<div
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
v-if="showPlansAndPricing"
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
data-testid="plans-pricing-menu-item"
@click="handleOpenPlansAndPricing"
>
<i class="icon-[lucide--receipt-text] text-muted-foreground text-sm" />
<span class="text-sm text-base-foreground flex-1">{{
<i class="icon-[lucide--receipt-text] text-sm text-muted-foreground" />
<span class="flex-1 text-sm text-base-foreground">{{
$t('subscription.plansAndPricing')
}}</span>
<span
v-if="canUpgrade"
class="text-xs font-bold text-base-background bg-base-foreground px-1.5 py-0.5 rounded-full"
class="rounded-full bg-base-foreground px-1.5 py-0.5 text-xs font-bold text-base-background"
>
{{ $t('subscription.upgrade') }}
</span>
</div>
<!-- Manage Plan (PERSONAL and OWNER, only if subscribed) -->
<div
v-if="isActiveSubscription"
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
v-if="showManagePlan"
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
data-testid="manage-plan-menu-item"
@click="handleOpenPlanAndCreditsSettings"
>
<i class="icon-[lucide--file-text] text-muted-foreground text-sm" />
<span class="text-sm text-base-foreground flex-1">{{
<i class="icon-[lucide--file-text] text-sm text-muted-foreground" />
<span class="flex-1 text-sm text-base-foreground">{{
$t('subscription.managePlan')
}}</span>
</div>
<!-- Partner Nodes Pricing (always shown) -->
<div
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
data-testid="partner-nodes-menu-item"
@click="handleOpenPartnerNodesInfo"
>
<i class="icon-[lucide--tag] text-sm text-muted-foreground" />
<span class="flex-1 text-sm text-base-foreground">{{
$t('subscription.partnerNodesCredits')
}}</span>
</div>
<Divider class="mx-0 my-2" />
<!-- Workspace Settings (always shown) -->
<div
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
data-testid="workspace-settings-menu-item"
@click="handleOpenWorkspaceSettings"
>
<i class="icon-[lucide--users] text-sm text-muted-foreground" />
<span class="flex-1 text-sm text-base-foreground">{{
$t('userSettings.workspaceSettings')
}}</span>
</div>
<!-- Account Settings (always shown) -->
<div
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
data-testid="user-settings-menu-item"
@click="handleOpenUserSettings"
>
<i class="icon-[lucide--settings-2] text-muted-foreground text-sm" />
<span class="text-sm text-base-foreground flex-1">{{
<i class="icon-[lucide--settings-2] text-sm text-muted-foreground" />
<span class="flex-1 text-sm text-base-foreground">{{
$t('userSettings.accountSettings')
}}</span>
</div>
<Divider class="my-2 mx-0" />
<Divider class="mx-0 my-2" />
<!-- Logout (always shown) -->
<div
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
data-testid="logout-menu-item"
@click="handleLogout"
>
<i class="icon-[lucide--log-out] text-muted-foreground text-sm" />
<span class="text-sm text-base-foreground flex-1">{{
<i class="icon-[lucide--log-out] text-sm text-muted-foreground" />
<span class="flex-1 text-sm text-base-foreground">{{
$t('auth.signOut.signOut')
}}</span>
</div>
@@ -137,12 +220,15 @@
<script setup lang="ts">
import Divider from 'primevue/divider'
import Popover from 'primevue/popover'
import Skeleton from 'primevue/skeleton'
import { computed, onMounted } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import UserAvatar from '@/components/common/UserAvatar.vue'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import WorkspaceSwitcherPopover from '@/components/topbar/WorkspaceSwitcherPopover.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
@@ -152,9 +238,18 @@ import { useSubscription } from '@/platform/cloud/subscription/composables/useSu
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkspace } from '@/platform/workspace/composables/useWorkspace'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const {
workspaceName,
workspaceRole,
isPersonalWorkspace,
isWorkspaceSubscribed
} = useWorkspace()
const workspaceSwitcherPopover = ref<InstanceType<typeof Popover> | null>(null)
const emit = defineEmits<{
close: []
}>()
@@ -197,11 +292,31 @@ const canUpgrade = computed(() => {
)
})
// Menu visibility based on role
// PERSONAL: Plans & pricing, Manage plan (if subscribed), Partner nodes, Divider, Workspace settings, Account settings, Divider, Log out
// MEMBER: Partner nodes, Divider, Workspace settings, Account settings, Divider, Log out
// OWNER (unsubscribed): Plans & pricing, Partner nodes, Divider, Workspace settings, Account settings, Divider, Log out
// OWNER (subscribed): Plans & pricing, Manage plan, Partner nodes, Divider, Workspace settings, Account settings, Divider, Log out
const showPlansAndPricing = computed(
() => isPersonalWorkspace.value || workspaceRole.value === 'OWNER'
)
const showManagePlan = computed(
() => showPlansAndPricing.value && isActiveSubscription.value
)
const showCreditsSection = computed(
() => isPersonalWorkspace.value || workspaceRole.value === 'OWNER'
)
const handleOpenUserSettings = () => {
dialogService.showSettingsDialog('user')
emit('close')
}
const handleOpenWorkspaceSettings = () => {
dialogService.showSettingsDialog('workspace')
emit('close')
}
const handleOpenPlansAndPricing = () => {
subscriptionDialog.show()
emit('close')
@@ -209,7 +324,7 @@ const handleOpenPlansAndPricing = () => {
const handleOpenPlanAndCreditsSettings = () => {
if (isCloud) {
dialogService.showSettingsDialog('workspace-plan')
dialogService.showSettingsDialog('workspace')
} else {
dialogService.showSettingsDialog('credits')
}
@@ -241,6 +356,18 @@ const handleSubscribed = async () => {
await fetchStatus()
}
const handleCreateWorkspace = () => {
workspaceSwitcherPopover.value?.hide()
dialogService.showCreateWorkspaceDialog(() => {
// TODO: Implement actual create workspace API call
})
emit('close')
}
const toggleWorkspaceSwitcher = (event: MouseEvent) => {
workspaceSwitcherPopover.value?.toggle(event)
}
onMounted(() => {
void authActions.fetchBalance()
})

View File

@@ -0,0 +1,123 @@
<template>
<div class="flex w-80 flex-col overflow-hidden rounded-lg">
<div class="flex flex-col overflow-y-auto">
<template
v-for="workspace in availableWorkspaces"
:key="workspace.id ?? 'personal'"
>
<div class="border-b border-border-default p-2">
<button
:class="
cn(
'flex h-[54px] w-full cursor-pointer items-center gap-2 rounded px-2 py-4 border-none bg-transparent',
'hover:bg-secondary-background-hover',
isCurrentWorkspace(workspace) && 'bg-secondary-background'
)
"
@click="handleSelectWorkspace(workspace)"
>
<WorkspaceProfilePic
class="size-8 text-sm"
: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.role !== 'PERSONAL'"
class="text-sm text-muted-foreground"
>
{{ getRoleLabel(workspace.role) }}
</span>
</div>
<i
v-if="isCurrentWorkspace(workspace)"
class="pi pi-check text-sm text-base-foreground"
/>
</button>
</div>
</template>
<!-- <Divider class="mx-0 my-0" /> -->
<!-- Create workspace button -->
<div class="px-2 py-2">
<div
:class="
cn(
'flex h-12 w-full items-center gap-2 rounded px-2 py-2',
canCreateWorkspace
? 'cursor-pointer hover:bg-secondary-background-hover'
: 'cursor-default'
)
"
@click="canCreateWorkspace && handleCreateWorkspace()"
>
<div
:class="
cn(
'flex size-8 items-center justify-center rounded-full bg-secondary-background',
!canCreateWorkspace && 'opacity-50'
)
"
>
<i class="pi pi-plus text-sm text-muted-foreground" />
</div>
<div class="flex min-w-0 flex-1 flex-col">
<span
v-if="canCreateWorkspace"
class="text-sm text-muted-foreground"
>
{{ $t('workspaceSwitcher.createWorkspace') }}
</span>
<span v-else class="text-sm text-muted-foreground">
{{ $t('workspaceSwitcher.maxWorkspacesReached') }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import type { AvailableWorkspace } from '@/platform/workspace/composables/useWorkspace'
import { useWorkspace } from '@/platform/workspace/composables/useWorkspace'
import { cn } from '@/utils/tailwindUtil'
const emit = defineEmits<{
select: [workspace: AvailableWorkspace]
create: []
}>()
const { t } = useI18n()
const {
workspaceId,
availableWorkspaces,
canCreateWorkspace,
switchWorkspace
} = useWorkspace()
function isCurrentWorkspace(workspace: AvailableWorkspace): boolean {
return workspace.id === workspaceId.value
}
function getRoleLabel(role: AvailableWorkspace['role']): string {
if (role === 'OWNER') return t('workspaceSwitcher.roleOwner')
if (role === 'MEMBER') return t('workspaceSwitcher.roleMember')
return ''
}
function handleSelectWorkspace(workspace: AvailableWorkspace) {
switchWorkspace(workspace)
emit('select', workspace)
}
function handleCreateWorkspace() {
emit('create')
}
</script>

View File

@@ -1,6 +1,7 @@
{
"g": {
"user": "User",
"you": "You",
"currentUser": "Current user",
"empty": "Empty",
"noWorkflowsFound": "No workflows found.",
@@ -2000,6 +2001,8 @@
"renewsDate": "Renews {date}",
"expiresDate": "Expires {date}",
"manageSubscription": "Manage subscription",
"managePayment": "Manage Payment",
"cancelSubscription": "Cancel Subscription",
"partnerNodesBalance": "\"Partner Nodes\" Credit Balance",
"partnerNodesDescription": "For running commercial/proprietary models",
"totalCredits": "Total credits",
@@ -2054,6 +2057,8 @@
"subscribeToRunFull": "Subscribe to Run",
"subscribeNow": "Subscribe Now",
"subscribeToComfyCloud": "Subscribe to Comfy Cloud",
"workspaceNotSubscribed": "This workspace is not on a subscription",
"subscriptionRequiredMessage": "A subscription is required for members to run workflows on Cloud",
"description": "Choose the best plan for you",
"haveQuestions": "Have questions or wondering about enterprise?",
"contactUs": "Contact us",
@@ -2089,6 +2094,7 @@
"userSettings": {
"title": "My Account Settings",
"accountSettings": "Account settings",
"workspaceSettings": "Workspace settings",
"name": "Name",
"email": "Email",
"provider": "Sign-in Provider",
@@ -2096,18 +2102,98 @@
"updatePassword": "Update Password"
},
"workspacePanel": {
"invite": "Invite",
"inviteMember": "Invite member",
"inviteLimitReached": "You've reached the maximum of 50 members",
"tabs": {
"dashboard": "Dashboard",
"planCredits": "Plan & Credits",
"members": "Members"
"membersCount": "Members ({count})"
},
"dashboard": {
"placeholder": "Dashboard workspace settings"
},
"members": {
"placeholder": "Member settings"
"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": {
"leaveWorkspace": "Leave Workspace",
"deleteWorkspace": "Delete Workspace",
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first"
},
"leaveDialog": {
"title": "Leave this workspace?",
"message": "You won't be able to join again unless you contact the workspace owner.",
"leave": "Leave"
},
"deleteDialog": {
"title": "Delete this workspace?",
"message": "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"
},
"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"
}
},
"createWorkspaceDialog": {
"title": "Create a new workspace",
"message": "Workspaces let members share a single credits pool. You'll become the owner after creating this.",
"nameLabel": "Workspace name*",
"namePlaceholder": "Enter workspace name",
"create": "Create"
},
"toast": {
"workspaceCreated": {
"title": "Workspace created",
"message": "Subscribe to a plan, invite teammates, and start collaborating.",
"subscribe": "Subscribe"
}
}
},
"workspaceSwitcher": {
"switchWorkspace": "Switch workspace",
"subscribe": "Subscribe",
"roleOwner": "Owner",
"roleMember": "Member",
"createWorkspace": "Create new workspace",
"maxWorkspacesReached": "You can only own 10 workspaces. Delete one to create a new one."
},
"selectionToolbox": {
"executeButton": {
"tooltip": "Execute to selected output nodes (Highlighted with orange border)",
@@ -2635,4 +2721,4 @@
"tokenExchangeFailed": "Failed to authenticate with workspace: {error}"
}
}
}
}

View File

@@ -3,66 +3,98 @@
<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-2">
<div class="text-sm font-bold text-text-primary">
{{ subscriptionTierName }}
<!-- OWNER Unsubscribed State -->
<template v-if="isOwnerUnsubscribed">
<div class="flex flex-col gap-2">
<div class="text-sm font-bold text-text-primary">
{{ $t('subscription.workspaceNotSubscribed') }}
</div>
<div class="text-sm text-text-secondary">
{{ $t('subscription.subscriptionRequiredMessage') }}
</div>
</div>
<div class="flex items-baseline gap-1 font-inter font-semibold">
<span class="text-2xl">${{ tierPrice }}</span>
<span class="text-base">{{
$t('subscription.perMonth')
}}</span>
</div>
<div
v-if="isActiveSubscription"
class="text-sm text-text-secondary"
<Button
variant="primary"
class="ml-auto rounded-lg px-4 py-2 text-sm font-normal"
@click="showSubscriptionDialog"
>
<template v-if="isCancelled">
{{
$t('subscription.expiresDate', {
date: formattedEndDate
})
}}
</template>
<template v-else>
{{
$t('subscription.renewsDate', {
date: formattedRenewalDate
})
}}
</template>
{{ $t('subscription.subscribeNow') }}
</Button>
</template>
<!-- Normal Subscribed State -->
<template v-else>
<div class="flex flex-col gap-2">
<div class="text-sm font-bold text-text-primary">
{{ subscriptionTierName }}
</div>
<div class="flex items-baseline gap-1 font-inter font-semibold">
<span class="text-2xl">${{ tierPrice }}</span>
<span class="text-base">{{ $t('subscription.perMonth') }}</span>
</div>
<div
v-if="isActiveSubscription"
class="text-sm text-text-secondary"
>
<template v-if="isCancelled">
{{
$t('subscription.expiresDate', {
date: formattedEndDate
})
}}
</template>
<template v-else>
{{
$t('subscription.renewsDate', {
date: formattedRenewalDate
})
}}
</template>
</div>
</div>
</div>
<Button
v-if="isActiveSubscription"
variant="secondary"
class="ml-auto rounded-lg px-4 py-2 text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
@click="
async () => {
await authActions.accessBillingPortal()
}
"
>
{{ $t('subscription.manageSubscription') }}
</Button>
<Button
v-if="isActiveSubscription"
variant="primary"
class="rounded-lg px-4 py-2 text-sm font-normal text-text-primary"
@click="showSubscriptionDialog"
>
{{ $t('subscription.upgradePlan') }}
</Button>
<template
v-if="isActiveSubscription && permissions.canManageSubscription"
>
<Button
variant="secondary"
class="ml-auto rounded-lg px-4 py-2 text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
@click="
async () => {
await authActions.accessBillingPortal()
}
"
>
{{ $t('subscription.managePayment') }}
</Button>
<Button
variant="primary"
class="rounded-lg px-4 py-2 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"
:aria-label="$t('g.moreOptions')"
@click="planMenu?.toggle($event)"
>
<i class="pi pi-ellipsis-h" />
</Button>
<Menu ref="planMenu" :model="planMenuItems" :popup="true" />
</template>
<SubscribeButton
v-else
:label="$t('subscription.subscribeNow')"
size="sm"
:fluid="false"
class="text-xs"
@subscribed="handleRefresh"
/>
<SubscribeButton
v-if="!isActiveSubscription"
:label="$t('subscription.subscribeNow')"
size="sm"
:fluid="false"
class="text-xs"
@subscribed="handleRefresh"
/>
</template>
</div>
</div>
@@ -91,13 +123,9 @@
<div class="text-sm text-muted">
{{ $t('subscription.totalCredits') }}
</div>
<Skeleton
v-if="isLoadingBalance"
width="8rem"
height="2rem"
/>
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
<div v-else class="text-2xl font-bold">
{{ totalCredits }}
{{ isOwnerUnsubscribed ? '0' : totalCredits }}
</div>
</div>
@@ -111,7 +139,9 @@
width="5rem"
height="1rem"
/>
<span v-else>{{ includedCreditsDisplay }}</span>
<span v-else>{{
isOwnerUnsubscribed ? '0 / 0' : includedCreditsDisplay
}}</span>
</td>
<td class="align-middle" :title="creditsRemainingLabel">
{{ creditsRemainingLabel }}
@@ -124,7 +154,9 @@
width="3rem"
height="1rem"
/>
<span v-else>{{ prepaidCredits }}</span>
<span v-else>{{
isOwnerUnsubscribed ? '0' : prepaidCredits
}}</span>
</td>
<td
class="align-middle"
@@ -146,7 +178,7 @@
{{ $t('subscription.viewUsageHistory') }}
</a>
<Button
v-if="isActiveSubscription"
v-if="isActiveSubscription && !isOwnerUnsubscribed"
variant="secondary"
class="p-2 min-h-8 rounded-lg text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
@click="handleAddApiCredits"
@@ -204,8 +236,9 @@
</template>
<script setup lang="ts">
import Menu from 'primevue/menu'
import Skeleton from 'primevue/skeleton'
import { computed, onBeforeUnmount, onMounted } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
@@ -222,11 +255,18 @@ import {
getTierFeatures,
getTierPrice
} from '@/platform/cloud/subscription/constants/tierPricing'
import { useWorkspace } from '@/platform/workspace/composables/useWorkspace'
import { cn } from '@/utils/tailwindUtil'
const authActions = useFirebaseAuthActions()
const { permissions, isWorkspaceSubscribed, workspaceRole } = useWorkspace()
const { t, n } = useI18n()
// OWNER with unsubscribed workspace
const isOwnerUnsubscribed = computed(
() => workspaceRole.value === 'OWNER' && !isWorkspaceSubscribed.value
)
const {
isActiveSubscription,
isCancelled,
@@ -240,6 +280,18 @@ const {
const { show: showSubscriptionDialog } = useSubscriptionDialog()
const planMenu = ref<InstanceType<typeof Menu> | null>(null)
const planMenuItems = computed(() => [
{
label: t('subscription.cancelSubscription'),
icon: 'pi pi-times',
command: async () => {
await authActions.accessBillingPortal()
}
}
])
const tierKey = computed(() => {
const tier = subscriptionTier.value
if (!tier) return DEFAULT_TIER_KEY

View File

@@ -23,8 +23,8 @@
class="w-full border-none bg-transparent"
>
<template #optiongroup="{ option }">
<Divider v-if="option.key !== 'workspace'" class="my-2" />
<h3 class="px-2 py-1 text-xs font-semibold uppercase text-muted">
<!-- <Divider v-if="option.key !== 'workspace'" class="my-2" /> -->
<h3 class="text-xs font-semibold uppercase text-muted m-0 pt-6 pb-2">
{{ option.label }}
</h3>
</template>
@@ -96,8 +96,6 @@ const { defaultPanel } = defineProps<{
| 'credits'
| 'subscription'
| 'workspace'
| 'workspace-plan'
| 'workspace-members'
}>()
const {

View File

@@ -12,7 +12,6 @@ import { normalizeI18nKey } from '@/utils/formatUtil'
import { buildTree } from '@/utils/treeUtil'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useWorkspace } from '@/platform/workspace/composables/useWorkspace'
interface SettingPanelItem {
node: SettingTreeNode
@@ -30,8 +29,6 @@ export function useSettingUI(
| 'credits'
| 'subscription'
| 'workspace'
| 'workspace-plan'
| 'workspace-members'
) {
const { t } = useI18n()
const { isLoggedIn } = useCurrentUser()
@@ -40,7 +37,6 @@ export function useSettingUI(
const { shouldRenderVueNodes } = useVueFeatureFlags()
const { isActiveSubscription } = useSubscription()
const { workspaceName } = useWorkspace()
const settingRoot = computed<SettingTreeNode>(() => {
const root = buildTree(
@@ -162,20 +158,6 @@ export function useSettingUI(
)
}
// Sidebar-only node for Plan & Credits (uses same WorkspacePanel component)
const workspacePlanNode: SettingTreeNode = {
key: 'workspace-plan',
label: 'WorkspacePlan',
children: []
}
// Sidebar-only node for Members (uses same WorkspacePanel component)
const workspaceMembersNode: SettingTreeNode = {
key: 'workspace-members',
label: 'WorkspaceMembers',
children: []
}
const keybindingPanel: SettingPanelItem = {
node: {
key: 'keybinding',
@@ -252,8 +234,6 @@ export function useSettingUI(
label: 'Workspace',
children: [
workspacePanel.node,
workspacePlanNode,
...(workspaceName.value ? [workspaceMembersNode] : []),
...(isLoggedIn.value &&
!(isCloud && window.__CONFIG__?.subscription_required)
? [creditsPanel.node]

View File

@@ -1,24 +1,428 @@
import { computed, ref } from 'vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
export type WorkspaceRole = 'PERSONAL' | 'MEMBER' | 'OWNER'
export interface WorkspaceMember {
id: string
name: string
email: string
joinDate: Date
}
export interface PendingInvite {
id: string
name: string
email: string
inviteDate: Date
expiryDate: Date
inviteLink: string
}
interface WorkspaceMockData {
id: string | null
name: string
role: WorkspaceRole
}
export interface AvailableWorkspace {
id: string | null
name: string
role: WorkspaceRole
}
/** Permission flags for workspace actions */
export interface WorkspacePermissions {
canViewOtherMembers: boolean
canViewPendingInvites: boolean
canInviteMembers: boolean
canManageInvites: boolean
canRemoveMembers: boolean
canLeaveWorkspace: boolean
canAccessWorkspaceMenu: boolean
canManageSubscription: boolean
}
/** UI configuration for workspace role */
export interface WorkspaceUIConfig {
showMembersList: boolean
showPendingTab: boolean
showSearch: boolean
showDateColumn: boolean
showRoleBadge: boolean
membersGridCols: string
pendingGridCols: string
headerGridCols: string
workspaceMenuAction: 'leave' | 'delete' | null
workspaceMenuDisabledTooltip: string | null
}
const ROLE_PERMISSIONS: Record<WorkspaceRole, WorkspacePermissions> = {
PERSONAL: {
canViewOtherMembers: false,
canViewPendingInvites: false,
canInviteMembers: false,
canManageInvites: false,
canRemoveMembers: false,
canLeaveWorkspace: false,
canAccessWorkspaceMenu: false,
canManageSubscription: true
},
MEMBER: {
canViewOtherMembers: true,
canViewPendingInvites: false,
canInviteMembers: false,
canManageInvites: false,
canRemoveMembers: false,
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: false
},
OWNER: {
canViewOtherMembers: true,
canViewPendingInvites: true,
canInviteMembers: true,
canManageInvites: true,
canRemoveMembers: true,
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: true
}
}
const ROLE_UI_CONFIG: Record<WorkspaceRole, WorkspaceUIConfig> = {
PERSONAL: {
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',
workspaceMenuAction: null,
workspaceMenuDisabledTooltip: null
},
MEMBER: {
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]',
workspaceMenuAction: 'leave',
workspaceMenuDisabledTooltip: null
},
OWNER: {
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%]',
workspaceMenuAction: 'delete',
workspaceMenuDisabledTooltip:
'workspacePanel.menu.deleteWorkspaceDisabledTooltip'
}
}
const MOCK_DATA: Record<WorkspaceRole, WorkspaceMockData> = {
PERSONAL: {
id: null,
name: 'Personal',
role: 'PERSONAL'
},
MEMBER: {
id: 'workspace-abc-123',
name: 'Acme Corp',
role: 'MEMBER'
},
OWNER: {
id: 'workspace-xyz-789',
name: 'Acme Corp',
role: 'OWNER'
}
}
/** Mock list of all available workspaces for the current user */
const MOCK_AVAILABLE_WORKSPACES: AvailableWorkspace[] = [
{ id: null, name: 'Personal workspace', role: 'PERSONAL' },
{ id: 'workspace-comfy-001', name: 'Team Comfy', role: 'OWNER' },
{ id: 'workspace-orange-002', name: 'OrangeDesignStudio', role: 'MEMBER' },
{ id: 'workspace-001', name: 'Workspace001', role: 'MEMBER' },
{ id: 'workspace-002', name: 'Workspace002', role: 'MEMBER' }
]
const MAX_OWNED_WORKSPACES = 10
const MOCK_MEMBERS: WorkspaceMember[] = [
{
id: '1',
name: 'Alice',
email: 'alice@example.com',
joinDate: new Date('2025-11-15')
},
{
id: '2',
name: 'Bob',
email: 'bob@example.com',
joinDate: new Date('2025-12-01')
},
{
id: '3',
name: 'Charlie',
email: 'charlie@example.com',
joinDate: new Date('2026-01-05')
}
]
const MOCK_PENDING_INVITES: PendingInvite[] = [
{
id: '1',
name: 'John',
email: 'john@gmail.com',
inviteDate: new Date('2026-01-02'),
expiryDate: new Date('2026-01-09'),
inviteLink: 'https://example.com/invite/abc123'
},
{
id: '2',
name: 'User102',
email: 'user102@gmail.com',
inviteDate: new Date('2026-01-01'),
expiryDate: new Date('2026-01-08'),
inviteLink: 'https://example.com/invite/def456'
},
{
id: '3',
name: 'User944',
email: 'user944@gmail.com',
inviteDate: new Date('2026-01-01'),
expiryDate: new Date('2026-01-08'),
inviteLink: 'https://example.com/invite/ghi789'
},
{
id: '4',
name: 'User45',
email: 'user45@gmail.com',
inviteDate: new Date('2025-12-15'),
expiryDate: new Date('2025-12-22'),
inviteLink: 'https://example.com/invite/jkl012'
},
{
id: '5',
name: 'User944',
email: 'user944@gmail.com',
inviteDate: new Date('2025-12-05'),
expiryDate: new Date('2025-12-22'),
inviteLink: 'https://example.com/invite/mno345'
}
]
// Constants
const MAX_WORKSPACE_MEMBERS = 50
// Shared state for workspace
const _workspaceName = ref<string | null>(null)
const _activeTab = ref<string>('general')
const _workspaceId = ref<string | null>(null)
const _workspaceName = ref<string>('Personal workspace')
const _workspaceRole = ref<WorkspaceRole>('PERSONAL')
const _isWorkspaceSubscribed = ref<boolean>(true)
const _activeTab = ref<string>('plan')
const _members = ref<WorkspaceMember[]>([])
const _pendingInvites = ref<PendingInvite[]>([])
const _availableWorkspaces = ref<AvailableWorkspace[]>(
MOCK_AVAILABLE_WORKSPACES
)
/**
* Set workspace mock state for testing UI
* Usage in browser console: window.__setWorkspaceRole('OWNER')
*/
function setMockRole(role: WorkspaceRole) {
const data = MOCK_DATA[role]
_workspaceId.value = data.id
_workspaceName.value = data.name
_workspaceRole.value = data.role
}
/**
* Set workspace subscription state for testing UI
* Usage in browser console: window.__setWorkspaceSubscribed(false)
*/
function setMockSubscribed(subscribed: boolean) {
_isWorkspaceSubscribed.value = subscribed
}
/**
* Switch to a different workspace
*/
function switchWorkspace(workspace: AvailableWorkspace) {
_workspaceId.value = workspace.id
_workspaceName.value = workspace.name
_workspaceRole.value = workspace.role
// Reset members/invites when switching
_members.value = []
_pendingInvites.value = []
}
// Expose to window for dev testing
if (typeof window !== 'undefined') {
;(
window as Window & {
__setWorkspaceRole?: typeof setMockRole
__setWorkspaceSubscribed?: typeof setMockSubscribed
}
).__setWorkspaceRole = setMockRole
;(
window as Window & { __setWorkspaceSubscribed?: typeof setMockSubscribed }
).__setWorkspaceSubscribed = setMockSubscribed
}
/**
* Composable for handling workspace data
* TODO: Replace stubbed data with actual API call
*/
export function useWorkspace() {
const { userDisplayName, userEmail } = useCurrentUser()
const workspaceId = computed(() => _workspaceId.value)
const workspaceName = computed(() => _workspaceName.value)
const workspaceRole = computed(() => _workspaceRole.value)
const activeTab = computed(() => _activeTab.value)
const isPersonalWorkspace = computed(
() => _workspaceRole.value === 'PERSONAL'
)
const isWorkspaceSubscribed = computed(() => _isWorkspaceSubscribed.value)
const permissions = computed<WorkspacePermissions>(
() => ROLE_PERMISSIONS[_workspaceRole.value]
)
const uiConfig = computed<WorkspaceUIConfig>(
() => ROLE_UI_CONFIG[_workspaceRole.value]
)
function setActiveTab(tab: string | number) {
_activeTab.value = String(tab)
}
const members = computed(() => _members.value)
const pendingInvites = computed(() => _pendingInvites.value)
const totalMemberSlots = computed(
() => _members.value.length + _pendingInvites.value.length
)
const isInviteLimitReached = computed(
() => totalMemberSlots.value >= MAX_WORKSPACE_MEMBERS
)
// TODO: Replace with actual API calls
async function fetchMembers(): Promise<WorkspaceMember[]> {
if (_workspaceRole.value === 'PERSONAL') {
_members.value = [
{
id: 'current-user',
name: userDisplayName.value ?? 'You',
email: userEmail.value ?? '',
joinDate: new Date()
}
]
} else {
_members.value = MOCK_MEMBERS
}
return _members.value
}
async function fetchPendingInvites(): Promise<PendingInvite[]> {
if (_workspaceRole.value === 'PERSONAL') {
_pendingInvites.value = []
} else {
_pendingInvites.value = MOCK_PENDING_INVITES
}
return _pendingInvites.value
}
async function revokeInvite(_inviteId: string): Promise<void> {
// TODO: API call to revoke invite
}
async function copyInviteLink(inviteId: string): Promise<string> {
const invite = _pendingInvites.value.find((i) => i.id === inviteId)
if (invite) {
await navigator.clipboard.writeText(invite.inviteLink)
return invite.inviteLink
}
throw new Error('Invite not found')
}
/**
* Create an invite link for a given email
* TODO: Replace with actual API call
*/
async function createInviteLink(email: string): Promise<string> {
// Simulate API delay
await new Promise((resolve) => setTimeout(resolve, 1000))
// Generate mock invite link
const inviteId = Math.random().toString(36).substring(2, 10)
const inviteLink = `https://cloud.comfy.org/workspace?3423532/invite/hi789jkl012mno345pq`
// Add to pending invites (mock)
const newInvite: PendingInvite = {
id: inviteId,
name: email.split('@')[0],
email,
inviteDate: new Date(),
expiryDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
inviteLink
}
_pendingInvites.value = [..._pendingInvites.value, newInvite]
return inviteLink
}
const availableWorkspaces = computed(() => _availableWorkspaces.value)
const ownedWorkspacesCount = computed(
() => _availableWorkspaces.value.filter((w) => w.role === 'OWNER').length
)
const canCreateWorkspace = computed(
() => ownedWorkspacesCount.value < MAX_OWNED_WORKSPACES
)
return {
workspaceId,
workspaceName,
workspaceRole,
activeTab,
setActiveTab
isPersonalWorkspace,
isWorkspaceSubscribed,
permissions,
uiConfig,
setActiveTab,
// Workspace switching
availableWorkspaces,
ownedWorkspacesCount,
canCreateWorkspace,
switchWorkspace,
// Members
members,
pendingInvites,
totalMemberSlots,
isInviteLimitReached,
fetchMembers,
fetchPendingInvites,
revokeInvite,
copyInviteLink,
createInviteLink,
// Dev helpers
setMockRole,
setMockSubscribed
}
}

View File

@@ -2,6 +2,12 @@ import { merge } from 'es-toolkit/compat'
import type { Component } from 'vue'
import ApiNodesSignInContent from '@/components/dialog/content/ApiNodesSignInContent.vue'
import CreateWorkspaceDialogContent from '@/components/dialog/content/workspace/CreateWorkspaceDialogContent.vue'
import DeleteWorkspaceDialogContent from '@/components/dialog/content/workspace/DeleteWorkspaceDialogContent.vue'
import InviteMemberDialogContent from '@/components/dialog/content/workspace/InviteMemberDialogContent.vue'
import LeaveWorkspaceDialogContent from '@/components/dialog/content/workspace/LeaveWorkspaceDialogContent.vue'
import RemoveMemberDialogContent from '@/components/dialog/content/workspace/RemoveMemberDialogContent.vue'
import RevokeInviteDialogContent from '@/components/dialog/content/workspace/RevokeInviteDialogContent.vue'
import MissingNodesContent from '@/components/dialog/content/MissingNodesContent.vue'
import MissingNodesFooter from '@/components/dialog/content/MissingNodesFooter.vue'
import MissingNodesHeader from '@/components/dialog/content/MissingNodesHeader.vue'
@@ -103,8 +109,6 @@ export const useDialogService = () => {
| 'credits'
| 'subscription'
| 'workspace'
| 'workspace-plan'
| 'workspace-members'
) {
const props = panel ? { props: { defaultPanel: panel } } : undefined
@@ -112,11 +116,6 @@ export const useDialogService = () => {
key: 'global-settings',
headerComponent: SettingDialogHeader,
component: SettingDialogContent,
dialogComponentProps: {
pt: {
root: { class: 'settings-dialog' }
}
},
...props
})
}
@@ -126,11 +125,6 @@ export const useDialogService = () => {
key: 'global-settings',
headerComponent: SettingDialogHeader,
component: SettingDialogContent,
dialogComponentProps: {
pt: {
root: { class: 'settings-dialog' }
}
},
props: {
defaultPanel: 'about'
}
@@ -532,6 +526,106 @@ export const useDialogService = () => {
show()
}
function showLeaveWorkspaceDialog(onConfirm: () => void | Promise<void>) {
return dialogStore.showDialog({
key: 'leave-workspace',
component: LeaveWorkspaceDialogContent,
props: { onConfirm },
dialogComponentProps: {
headless: true,
pt: {
header: { class: 'p-0! hidden' },
content: { class: 'p-0! m-0! rounded-2xl' },
root: { class: 'rounded-2xl' }
}
}
})
}
function showDeleteWorkspaceDialog(onConfirm: () => void | Promise<void>) {
return dialogStore.showDialog({
key: 'delete-workspace',
component: DeleteWorkspaceDialogContent,
props: { onConfirm },
dialogComponentProps: {
headless: true,
pt: {
header: { class: 'p-0! hidden' },
content: { class: 'p-0! m-0! rounded-2xl' },
root: { class: 'rounded-2xl' }
}
}
})
}
function showRemoveMemberDialog(onConfirm: () => void | Promise<void>) {
return dialogStore.showDialog({
key: 'remove-member',
component: RemoveMemberDialogContent,
props: { onConfirm },
dialogComponentProps: {
headless: true,
pt: {
header: { class: 'p-0! hidden' },
content: { class: 'p-0! m-0! rounded-2xl' },
root: { class: 'rounded-2xl' }
}
}
})
}
function showRevokeInviteDialog(onConfirm: () => void | Promise<void>) {
return dialogStore.showDialog({
key: 'revoke-invite',
component: RevokeInviteDialogContent,
props: { onConfirm },
dialogComponentProps: {
headless: true,
pt: {
header: { class: 'p-0! hidden' },
content: { class: 'p-0! m-0! rounded-2xl' },
root: { class: 'rounded-2xl' }
}
}
})
}
function showInviteMemberDialog(
onConfirm: (email: string) => void | Promise<void>
) {
return dialogStore.showDialog({
key: 'invite-member',
component: InviteMemberDialogContent,
props: { onConfirm },
dialogComponentProps: {
headless: true,
pt: {
header: { class: 'p-0! hidden' },
content: { class: 'p-0! m-0! rounded-2xl' },
root: { class: 'rounded-2xl max-w-[512px] w-full' }
}
}
})
}
function showCreateWorkspaceDialog(
onConfirm: (name: string) => void | Promise<void>
) {
return dialogStore.showDialog({
key: 'create-workspace',
component: CreateWorkspaceDialogContent,
props: { onConfirm },
dialogComponentProps: {
headless: true,
pt: {
header: { class: 'p-0! hidden' },
content: { class: 'p-0! m-0! rounded-2xl' },
root: { class: 'rounded-2xl max-w-[400px] w-full' }
}
}
})
}
return {
showLoadWorkflowWarning,
showMissingModelsWarning,
@@ -543,6 +637,12 @@ export const useDialogService = () => {
showSubscriptionRequiredDialog,
showTopUpCreditsDialog,
showUpdatePasswordDialog,
showLeaveWorkspaceDialog,
showDeleteWorkspaceDialog,
showRemoveMemberDialog,
showRevokeInviteDialog,
showInviteMemberDialog,
showCreateWorkspaceDialog,
showExtensionDialog,
prompt,
showErrorDialog,

View File

@@ -17,6 +17,7 @@
<GlobalToast />
<RerouteMigrationToast />
<WorkspaceCreatedToast />
<ModelImportProgressDialog />
<ManagerProgressToast />
<UnloadWindowConfirmDialog v-if="!isElectron()" />
@@ -45,6 +46,7 @@ import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDi
import GraphCanvas from '@/components/graph/GraphCanvas.vue'
import GlobalToast from '@/components/toast/GlobalToast.vue'
import RerouteMigrationToast from '@/components/toast/RerouteMigrationToast.vue'
import WorkspaceCreatedToast from '@/components/toast/WorkspaceCreatedToast.vue'
import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
import { useCoreCommands } from '@/composables/useCoreCommands'
import { useErrorHandling } from '@/composables/useErrorHandling'