mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-02 06:19:58 +00:00
feat: implemented workspace flow
This commit is contained in:
43
src/components/common/WorkspaceProfilePic.vue
Normal file
43
src/components/common/WorkspaceProfilePic.vue
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
470
src/components/dialog/content/setting/MembersPanelContent.vue
Normal file
470
src/components/dialog/content/setting/MembersPanelContent.vue
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
44
src/components/toast/WorkspaceCreatedToast.vue
Normal file
44
src/components/toast/WorkspaceCreatedToast.vue
Normal 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>
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
123
src/components/topbar/WorkspaceSwitcherPopover.vue
Normal file
123
src/components/topbar/WorkspaceSwitcherPopover.vue
Normal 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>
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user