Compare commits

...

12 Commits

Author SHA1 Message Date
--list
d04722109c refactor: removed dead code, added i18n vaues, misc improvements 2026-01-20 00:28:18 -08:00
--list
84d30fe121 feat: FF the feature and also added isCloud checks and misc styling fixes 2026-01-19 23:27:38 -08:00
--list
7bb647623c refactor: use feature flags but override true 2026-01-19 20:36:25 -08:00
--list
f1ff6156c4 refactor: remove unused types 2026-01-19 18:51:57 -08:00
--list
239b0698c6 refactor: debug naming and misc 2026-01-19 18:41:23 -08:00
--list
d6bdf4feff feat: invite and members working 2026-01-19 15:30:20 -08:00
--list
bc698fb746 feat: workspace switcher and misc 2026-01-17 14:23:28 -08:00
--list
99d91dd53b feat: add workspace API and refactor useWorkspace
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 14:23:28 -08:00
--list
8950b7327f feat: implemented workspace flow 2026-01-17 14:23:28 -08:00
--list
68bca07914 feat: more of the flow and fixes 2026-01-17 14:23:27 -08:00
--list
e419a76b5e feat: implemented workspace flow 2026-01-17 14:23:27 -08:00
--list
a89a48d11e feat: new settings 2026-01-17 14:23:27 -08:00
47 changed files with 7054 additions and 1466 deletions

View File

@@ -0,0 +1,43 @@
<template>
<div
class="flex size-6 items-center justify-center rounded-md text-base font-semibold text-white"
:style="{
background: gradient,
textShadow: '0 1px 2px rgba(0, 0, 0, 0.2)'
}"
>
{{ letter }}
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const { workspaceName } = defineProps<{
workspaceName: string
}>()
const letter = computed(() => workspaceName?.charAt(0)?.toUpperCase() ?? '?')
const gradient = computed(() => {
const seed = letter.value.charCodeAt(0)
function mulberry32(a: number) {
return function () {
let t = (a += 0x6d2b79f5)
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
const rand = mulberry32(seed)
const hue1 = Math.floor(rand() * 360)
const hue2 = (hue1 + 40 + Math.floor(rand() * 80)) % 360
const sat = 65 + Math.floor(rand() * 20)
const light = 55 + Math.floor(rand() * 15)
return `linear-gradient(135deg, hsl(${hue1}, ${sat}%, ${light}%), hsl(${hue2}, ${sat}%, ${light}%))`
})
</script>

View File

@@ -4,7 +4,12 @@
v-for="item in dialogStore.dialogStack"
:key="item.key"
v-model:visible="item.visible"
class="global-dialog"
:class="[
'global-dialog',
item.key === 'global-settings' && teamWorkspacesEnabled
? 'settings-dialog-workspace'
: ''
]"
v-bind="item.dialogComponentProps"
:pt="item.dialogComponentProps.pt"
:aria-labelledby="item.key"
@@ -38,8 +43,13 @@
<script setup lang="ts">
import Dialog from 'primevue/dialog'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useDialogStore } from '@/stores/dialogStore'
const { flags } = useFeatureFlags()
const teamWorkspacesEnabled = isCloud && flags.teamWorkspacesEnabled
const dialogStore = useDialogStore()
</script>
@@ -55,4 +65,27 @@ const dialogStore = useDialogStore()
@apply p-2 2xl:p-[var(--p-dialog-content-padding)];
@apply pt-0;
}
/* Workspace mode: wider settings dialog */
.settings-dialog-workspace {
width: 100%;
max-width: 1440px;
}
.settings-dialog-workspace .p-dialog-content {
width: 100%;
}
.manager-dialog {
height: 80vh;
max-width: 1724px;
max-height: 1026px;
}
@media (min-width: 3000px) {
.manager-dialog {
max-width: 2200px;
max-height: 1320px;
}
}
</style>

View File

@@ -0,0 +1,483 @@
<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">
{{ getInviteInitial(invite.email) }}
</span>
</div>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<span class="text-sm text-base-foreground">
{{ getInviteDisplayName(invite.email) }}
</span>
<span class="text-sm text-muted-foreground">
{{ invite.email }}
</span>
</div>
</div>
<!-- Invite date -->
<span class="text-sm text-muted-foreground">
{{ formatDate(invite.inviteDate) }}
</span>
<!-- Expiry date -->
<span class="text-sm text-muted-foreground">
{{ formatDate(invite.expiryDate) }}
</span>
<!-- Actions -->
<div class="flex items-center justify-end gap-2">
<Button
v-tooltip="{
value: $t('workspacePanel.members.actions.copyLink'),
showDelay: 300
}"
variant="secondary"
size="md"
:aria-label="$t('workspacePanel.members.actions.copyLink')"
@click="handleCopyInviteLink(invite)"
>
<i class="icon-[lucide--link] size-4" />
</Button>
<Button
v-tooltip="{
value: $t('workspacePanel.members.actions.revokeInvite'),
showDelay: 300
}"
variant="secondary"
size="md"
:aria-label="$t('workspacePanel.members.actions.revokeInvite')"
@click="handleRevokeInvite(invite)"
>
<i class="icon-[lucide--mail-x] size-4" />
</Button>
</div>
</div>
<div
v-if="filteredPendingInvites.length === 0"
class="flex w-full items-center justify-center py-8 text-sm text-muted-foreground"
>
{{ $t('workspacePanel.members.noInvites') }}
</div>
</template>
</div>
</div>
</div>
<!-- Personal Workspace Message -->
<div v-if="isPersonalWorkspace" class="flex items-center">
<p class="text-sm text-muted-foreground">
{{ $t('workspacePanel.members.personalWorkspaceMessage') }}
</p>
<button
class="underline bg-transparent border-none cursor-pointer"
@click="handleCreateWorkspace"
>
{{ $t('workspacePanel.members.createNewWorkspace') }}
</button>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import Menu from 'primevue/menu'
import { useToast } from 'primevue/usetoast'
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 { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import type {
PendingInvite,
WorkspaceMember
} from '@/platform/workspace/stores/teamWorkspaceStore'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogService } from '@/services/dialogService'
import { cn } from '@/utils/tailwindUtil'
const { d, t } = useI18n()
const toast = useToast()
const { userPhotoUrl, userEmail, userDisplayName } = useCurrentUser()
const {
showRemoveMemberDialog,
showRevokeInviteDialog,
showCreateWorkspaceDialog
} = useDialogService()
const workspaceStore = useTeamWorkspaceStore()
const {
members,
pendingInvites,
isInPersonalWorkspace: isPersonalWorkspace
} = storeToRefs(workspaceStore)
const { fetchMembers, fetchPendingInvites, copyInviteLink } = workspaceStore
const { permissions, uiConfig, workspaceRole } = useWorkspaceUI()
const searchQuery = ref('')
const activeView = ref<'active' | 'pending'>('active')
const sortField = ref<'inviteDate' | 'expiryDate' | 'joinDate'>('inviteDate')
const sortDirection = ref<'asc' | 'desc'>('desc')
const memberMenu = ref<InstanceType<typeof Menu> | null>(null)
const selectedMember = ref<WorkspaceMember | null>(null)
function getInviteDisplayName(email: string): string {
return email.split('@')[0]
}
function getInviteInitial(email: string): string {
return email.charAt(0).toUpperCase()
}
const memberMenuItems = computed(() => [
{
label: t('workspacePanel.members.actions.removeMember'),
icon: 'pi pi-user-minus',
command: () => {
if (selectedMember.value) {
handleRemoveMember(selectedMember.value)
}
}
}
])
function showMemberMenu(event: Event, member: WorkspaceMember) {
selectedMember.value = member
memberMenu.value?.toggle(event)
}
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.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' })
}
async function handleCopyInviteLink(invite: PendingInvite) {
try {
await copyInviteLink(invite.id)
toast.add({
severity: 'success',
summary: t('g.copied'),
life: 2000
})
} catch {
toast.add({
severity: 'error',
summary: t('g.error'),
life: 3000
})
}
}
function handleRevokeInvite(invite: PendingInvite) {
showRevokeInviteDialog(invite.id)
}
function handleCreateWorkspace() {
showCreateWorkspaceDialog()
}
function handleRemoveMember(member: WorkspaceMember) {
showRemoveMemberDialog(member.id)
}
</script>

View File

@@ -0,0 +1,11 @@
<template>
<TabPanel value="Workspace" class="h-full">
<WorkspacePanelContent />
</TabPanel>
</template>
<script setup lang="ts">
import TabPanel from 'primevue/tabpanel'
import WorkspacePanelContent from '@/components/dialog/content/setting/WorkspacePanelContent.vue'
</script>

View File

@@ -0,0 +1,201 @@
<template>
<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">
<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="plan">
<SubscriptionPanelContent />
</TabPanel>
<TabPanel value="members">
<MembersPanelContent :key="workspaceRole" />
</TabPanel>
</TabPanels>
</Tabs>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
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 { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import MembersPanelContent from '@/components/dialog/content/setting/MembersPanelContent.vue'
import Button from '@/components/ui/button/Button.vue'
import SubscriptionPanelContent from '@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogService } from '@/services/dialogService'
const { defaultTab = 'plan' } = defineProps<{
defaultTab?: string
}>()
const { t } = useI18n()
const {
showLeaveWorkspaceDialog,
showDeleteWorkspaceDialog,
showInviteMemberDialog,
showEditWorkspaceDialog
} = useDialogService()
const workspaceStore = useTeamWorkspaceStore()
const { workspaceName, members, isInviteLimitReached, isWorkspaceSubscribed } =
storeToRefs(workspaceStore)
const { fetchMembers, fetchPendingInvites } = workspaceStore
const { activeTab, setActiveTab, workspaceRole, permissions, uiConfig } =
useWorkspaceUI()
const menu = ref<InstanceType<typeof Menu> | null>(null)
function handleLeaveWorkspace() {
showLeaveWorkspaceDialog()
}
function handleDeleteWorkspace() {
showDeleteWorkspaceDialog()
}
function handleEditWorkspace() {
showEditWorkspaceDialog()
}
// Disable delete when workspace has an active subscription (to prevent accidental deletion)
// Use workspace's own subscription status, not the global isActiveSubscription
const isDeleteDisabled = computed(
() =>
uiConfig.value.workspaceMenuAction === 'delete' &&
isWorkspaceSubscribed.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()
}
const menuItems = computed(() => {
const items = []
// Add edit option for owners
if (uiConfig.value.showEditWorkspaceMenuItem) {
items.push({
label: t('workspacePanel.menu.editWorkspace'),
icon: 'pi pi-pencil',
command: handleEditWorkspace
})
}
const action = uiConfig.value.workspaceMenuAction
if (action === 'delete') {
items.push({
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
})
} else if (action === 'leave') {
items.push({
label: t('workspacePanel.menu.leaveWorkspace'),
icon: 'pi pi-sign-out',
command: handleLeaveWorkspace
})
}
return items
})
onMounted(() => {
setActiveTab(defaultTab)
fetchMembers()
fetchPendingInvites()
})
</script>

View File

@@ -0,0 +1,19 @@
<template>
<div class="flex items-center gap-2">
<WorkspaceProfilePic
class="size-6 text-xs"
:workspace-name="workspaceName"
/>
<span>{{ workspaceName }}</span>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
const { workspaceName } = storeToRefs(useTeamWorkspaceStore())
</script>

View File

@@ -0,0 +1,113 @@
<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 { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const { onConfirm } = defineProps<{
onConfirm?: (name: string) => void | Promise<void>
}>()
const { t } = useI18n()
const dialogStore = useDialogStore()
const toast = useToast()
const workspaceStore = useTeamWorkspaceStore()
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 {
const name = workspaceName.value.trim()
// Call optional callback if provided
await onConfirm?.(name)
dialogStore.closeDialog({ key: 'create-workspace' })
// Create workspace and switch to it (triggers reload internally)
await workspaceStore.createWorkspace(name)
} catch (error) {
console.error('[CreateWorkspaceDialog] Failed to create workspace:', error)
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToCreateWorkspace'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
})
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,89 @@
<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">
{{
workspaceName
? $t('workspacePanel.deleteDialog.messageWithName', {
name: workspaceName
})
: $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 { useToast } from 'primevue/usetoast'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const { workspaceId, workspaceName } = defineProps<{
workspaceId?: string
workspaceName?: string
}>()
const { t } = useI18n()
const toast = useToast()
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
const loading = ref(false)
function onCancel() {
dialogStore.closeDialog({ key: 'delete-workspace' })
}
async function onDelete() {
loading.value = true
try {
// Delete workspace (uses workspaceId if provided, otherwise current workspace)
await workspaceStore.deleteWorkspace(workspaceId)
dialogStore.closeDialog({ key: 'delete-workspace' })
window.location.reload()
} catch (error) {
console.error('[DeleteWorkspaceDialog] Failed to delete workspace:', error)
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToDeleteWorkspace'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
})
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,104 @@
<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.editWorkspaceDialog.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">
<div class="flex flex-col gap-2">
<label class="text-sm text-base-foreground">
{{ $t('workspacePanel.editWorkspaceDialog.nameLabel') }}
</label>
<input
v-model="newWorkspaceName"
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"
@keydown.enter="isValidName && onSave()"
/>
</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="onSave"
>
{{ $t('workspacePanel.editWorkspaceDialog.save') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const { t } = useI18n()
const toast = useToast()
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
const loading = ref(false)
const newWorkspaceName = ref(workspaceStore.workspaceName)
const isValidName = computed(() => {
const name = newWorkspaceName.value.trim()
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_]*$/
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
})
function onCancel() {
dialogStore.closeDialog({ key: 'edit-workspace' })
}
async function onSave() {
if (!isValidName.value) return
loading.value = true
try {
await workspaceStore.updateWorkspaceName(newWorkspaceName.value.trim())
dialogStore.closeDialog({ key: 'edit-workspace' })
toast.add({
severity: 'success',
summary: t('workspacePanel.toast.workspaceUpdated.title'),
detail: t('workspacePanel.toast.workspaceUpdated.message'),
life: 5000
})
} catch (error) {
console.error('[EditWorkspaceDialog] Failed to update workspace:', error)
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToUpdateWorkspace'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
})
} finally {
loading.value = false
}
}
</script>

View File

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

View File

@@ -0,0 +1,78 @@
<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 { useToast } from 'primevue/usetoast'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const { t } = useI18n()
const toast = useToast()
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
const loading = ref(false)
function onCancel() {
dialogStore.closeDialog({ key: 'leave-workspace' })
}
async function onLeave() {
loading.value = true
try {
// leaveWorkspace() handles switching to personal workspace internally and reloads
await workspaceStore.leaveWorkspace()
dialogStore.closeDialog({ key: 'leave-workspace' })
window.location.reload()
} catch (error) {
console.error('[LeaveWorkspaceDialog] Failed to leave workspace:', error)
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToLeaveWorkspace'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
})
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,68 @@
<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 { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const { memberId } = defineProps<{
memberId: string
}>()
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
const loading = ref(false)
function onCancel() {
dialogStore.closeDialog({ key: 'remove-member' })
}
async function onRemove() {
loading.value = true
try {
await workspaceStore.removeMember(memberId)
dialogStore.closeDialog({ key: 'remove-member' })
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,68 @@
<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 { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const { inviteId } = defineProps<{
inviteId: string
}>()
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
const loading = ref(false)
function onCancel() {
dialogStore.closeDialog({ key: 'revoke-invite' })
}
async function onRevoke() {
loading.value = true
try {
await workspaceStore.revokeInvite(inviteId)
dialogStore.closeDialog({ key: 'revoke-invite' })
} finally {
loading.value = false
}
}
</script>

View File

@@ -126,11 +126,13 @@ import { useNodeBadge } from '@/composables/node/useNodeBadge'
import { useCanvasDrop } from '@/composables/useCanvasDrop'
import { useContextMenuTranslation } from '@/composables/useContextMenuTranslation'
import { useCopy } from '@/composables/useCopy'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
import { usePaste } from '@/composables/usePaste'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { mergeCustomNodesI18n, t } from '@/i18n'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { isCloud } from '@/platform/distribution/types'
import { useLitegraphSettings } from '@/platform/settings/composables/useLitegraphSettings'
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -394,6 +396,7 @@ const loadCustomNodesI18n = async () => {
const comfyAppReady = ref(false)
const workflowPersistence = useWorkflowPersistence()
const { flags } = useFeatureFlags()
useCanvasDrop(canvasRef)
useLitegraphSettings()
useNodeBadge()
@@ -459,6 +462,13 @@ onMounted(async () => {
// Load template from URL if present
await workflowPersistence.loadTemplateFromUrlIfPresent()
// Accept workspace invite from URL if present (e.g., ?invite=TOKEN)
if (isCloud && flags.teamWorkspacesEnabled) {
const { useInviteUrlLoader } =
await import('@/platform/workspace/composables/useInviteUrlLoader')
await useInviteUrlLoader().loadInviteFromUrl()
}
// Initialize release store to fetch releases from comfy-api (fire-and-forget)
const { useReleaseStore } =
await import('@/platform/updates/common/releaseStore')

View File

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

View File

@@ -1,4 +1,4 @@
<!-- A button that shows current authenticated user's avatar -->
<!-- A button that shows workspace icon (Cloud) or user avatar -->
<template>
<div>
<Button
@@ -16,7 +16,16 @@
)
"
>
<UserAvatar :photo-url="photoURL" :class="compact && 'size-full'" />
<WorkspaceProfilePic
v-if="showWorkspaceIcon"
:workspace-name="workspaceName"
:class="compact && 'size-full'"
/>
<UserAvatar
v-else
:photo-url="photoURL"
:class="compact && 'size-full'"
/>
<i v-if="showArrow" class="icon-[lucide--chevron-down] size-3 px-1" />
</div>
@@ -27,38 +36,65 @@
:show-arrow="false"
:pt="{
root: {
class: 'rounded-lg'
class: 'rounded-lg w-80'
}
}"
>
<CurrentUserPopover @close="closePopover" />
<!-- Workspace mode: workspace-aware popover -->
<CurrentUserPopoverWorkspace
v-if="teamWorkspacesEnabled"
@close="closePopover"
/>
<!-- Legacy mode: original popover -->
<CurrentUserPopover v-else @close="closePopover" />
</Popover>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import { computed, defineAsyncComponent, ref } from 'vue'
import UserAvatar from '@/components/common/UserAvatar.vue'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@/utils/tailwindUtil'
import CurrentUserPopover from './CurrentUserPopover.vue'
const CurrentUserPopoverWorkspace = defineAsyncComponent(
() => import('./CurrentUserPopoverWorkspace.vue')
)
const { showArrow = true, compact = false } = defineProps<{
showArrow?: boolean
compact?: boolean
}>()
const { flags } = useFeatureFlags()
const teamWorkspacesEnabled = computed(() => flags.teamWorkspacesEnabled)
const { isLoggedIn, userPhotoUrl } = useCurrentUser()
const popover = ref<InstanceType<typeof Popover> | null>(null)
const photoURL = computed<string | undefined>(
() => userPhotoUrl.value ?? undefined
)
const showWorkspaceIcon = computed(() => isCloud && teamWorkspacesEnabled.value)
const workspaceName = computed(() => {
if (!showWorkspaceIcon.value) return ''
const { workspaceName } = storeToRefs(useTeamWorkspaceStore())
return workspaceName.value
})
const popover = ref<InstanceType<typeof Popover> | null>(null)
const closePopover = () => {
popover.value?.hide()
}

View File

@@ -27,6 +27,27 @@ vi.mock('firebase/auth', () => ({
// Mock pinia
vi.mock('pinia')
// Mock toast store (needed by useWorkspace -> useInviteUrlLoader)
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => ({
add: vi.fn()
}))
}))
// Mock useWorkspace composable
vi.mock('@/platform/workspace/composables/useWorkspace', () => ({
useWorkspace: vi.fn(() => ({
workspaceName: { value: 'Test Workspace' },
workspaceId: { value: 'test-workspace-id' },
workspaceType: { value: 'personal' },
workspaceRole: { value: 'owner' },
isPersonalWorkspace: { value: true },
availableWorkspaces: { value: [] },
fetchWorkspaces: vi.fn(),
switchWorkspace: vi.fn()
}))
}))
// Mock showSettingsDialog and showTopUpCreditsDialog
const mockShowSettingsDialog = vi.fn()
const mockShowTopUpCreditsDialog = vi.fn()

View File

@@ -0,0 +1,347 @@
<!-- A popover that shows current user information and actions -->
<template>
<div
class="current-user-popover w-80 -m-3 p-2 rounded-lg border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
>
<!-- User Info Section -->
<div class="flex flex-col items-center px-0 py-3 mb-4">
<UserAvatar
class="mb-1"
:photo-url="userPhotoUrl"
:pt:icon:class="{
'text-2xl!': !userPhotoUrl
}"
size="large"
/>
<!-- User Details -->
<h3 class="my-0 mb-1 truncate text-base font-bold text-base-foreground">
{{ userDisplayName || $t('g.user') }}
</h3>
<p v-if="userEmail" class="my-0 truncate text-sm text-muted">
{{ userEmail }}
</p>
<!-- <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> -->
</div>
<!-- Workspace Selector -->
<div
class="flex cursor-pointer items-center justify-between rounded-lg px-4 py-2 hover:bg-secondary-background-hover"
@click="toggleWorkspaceSwitcher"
>
<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="workspaceTierName"
class="shrink-0 rounded bg-secondary-background-hover px-1.5 py-0.5 text-xs"
>
{{ workspaceTierName }}
</div>
<span v-else class="shrink-0 text-xs text-muted-foreground">
{{ $t('workspaceSwitcher.subscribe') }}
</span>
</div>
<i class="pi pi-chevron-down shrink-0 text-sm text-muted-foreground" />
</div>
<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">
<div class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<Skeleton
v-if="isLoadingBalance"
width="4rem"
height="1.25rem"
class="w-full"
/>
<span v-else class="text-base font-semibold text-base-foreground">{{
displayedCredits
}}</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"
/>
<!-- Subscribed: Show Add Credits button -->
<Button
v-if="isActiveSubscription && isWorkspaceSubscribed"
variant="secondary"
size="sm"
class="text-base-foreground"
data-testid="add-credits-button"
@click="handleTopUp"
>
{{ $t('subscription.addCredits') }}
</Button>
<!-- Unsubscribed: Show Subscribe button -->
<SubscribeButton
v-else
:fluid="false"
:label="$t('workspaceSwitcher.subscribe')"
size="sm"
variant="gradient"
@subscribed="handleSubscribed"
/>
</div>
<Divider class="mx-0 my-2" />
</template>
<!-- Plans & Pricing (PERSONAL and OWNER only) -->
<div
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-sm text-muted-foreground" />
<span class="flex-1 text-sm text-base-foreground">{{
$t('subscription.plansAndPricing')
}}</span>
<span
v-if="canUpgrade"
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="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-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 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-sm text-muted-foreground" />
<span class="flex-1 text-sm text-base-foreground">{{
$t('userSettings.accountSettings')
}}</span>
</div>
<Divider class="mx-0 my-2" />
<!-- Logout (always shown) -->
<div
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-sm text-muted-foreground" />
<span class="flex-1 text-sm text-base-foreground">{{
$t('auth.signOut.signOut')
}}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import Divider from 'primevue/divider'
import Popover from 'primevue/popover'
import Skeleton from 'primevue/skeleton'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import UserAvatar from '@/components/common/UserAvatar.vue'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
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'
import { useExternalLink } from '@/composables/useExternalLink'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogService } from '@/services/dialogService'
const workspaceStore = useTeamWorkspaceStore()
const {
workspaceName,
isInPersonalWorkspace: isPersonalWorkspace,
isWorkspaceSubscribed,
subscriptionPlan
} = storeToRefs(workspaceStore)
const { workspaceRole } = useWorkspaceUI()
const workspaceSwitcherPopover = ref<InstanceType<typeof Popover> | null>(null)
const emit = defineEmits<{
close: []
}>()
const { buildDocsUrl, docsPaths } = useExternalLink()
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
useCurrentUser()
const authActions = useFirebaseAuthActions()
const dialogService = useDialogService()
const { isActiveSubscription, fetchStatus } = useSubscription()
const { totalCredits, isLoadingBalance } = useSubscriptionCredits()
const subscriptionDialog = useSubscriptionDialog()
const { t } = useI18n()
const displayedCredits = computed(() =>
isWorkspaceSubscribed.value ? totalCredits.value : '0'
)
// Workspace subscription tier name (not user tier)
const workspaceTierName = computed(() => {
if (!isWorkspaceSubscribed.value) return null
if (!subscriptionPlan.value) return null
// Convert plan to display name
if (subscriptionPlan.value === 'PRO_MONTHLY')
return t('subscription.tiers.pro.name')
if (subscriptionPlan.value === 'PRO_YEARLY')
return t('subscription.tierNameYearly', {
name: t('subscription.tiers.pro.name')
})
return null
})
const canUpgrade = computed(() => {
// PRO is currently the only/highest tier, so no upgrades available
// This will need updating when additional tiers are added
return false
})
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')
}
const handleOpenPlanAndCreditsSettings = () => {
if (isCloud) {
dialogService.showSettingsDialog('workspace')
} else {
dialogService.showSettingsDialog('credits')
}
emit('close')
}
const handleTopUp = () => {
// Track purchase credits entry from avatar popover
useTelemetry()?.trackAddApiCreditButtonClicked()
dialogService.showTopUpCreditsDialog()
emit('close')
}
const handleOpenPartnerNodesInfo = () => {
window.open(
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),
'_blank'
)
emit('close')
}
const handleLogout = async () => {
await handleSignOut()
emit('close')
}
const handleSubscribed = async () => {
await fetchStatus()
}
const handleCreateWorkspace = () => {
workspaceSwitcherPopover.value?.hide()
dialogService.showCreateWorkspaceDialog()
emit('close')
}
const toggleWorkspaceSwitcher = (event: MouseEvent) => {
workspaceSwitcherPopover.value?.toggle(event)
}
onMounted(() => {
void authActions.fetchBalance()
})
</script>

View File

@@ -0,0 +1,192 @@
<template>
<div class="flex w-80 flex-col overflow-hidden rounded-lg">
<div class="flex flex-col overflow-y-auto">
<!-- Loading state -->
<div v-if="isFetchingWorkspaces" class="flex flex-col gap-2 p-2">
<div
v-for="i in 2"
:key="i"
class="flex h-[54px] animate-pulse items-center gap-2 rounded px-2 py-4"
>
<div class="size-8 rounded-full bg-secondary-background" />
<div class="flex flex-1 flex-col gap-1">
<div class="h-4 w-24 rounded bg-secondary-background" />
<div class="h-3 w-16 rounded bg-secondary-background" />
</div>
</div>
</div>
<!-- Workspace list -->
<template v-else>
<template v-for="workspace in availableWorkspaces" :key="workspace.id">
<div class="border-b border-border-default p-2">
<div
:class="
cn(
'group flex h-[54px] w-full items-center gap-2 rounded px-2 py-4',
'hover:bg-secondary-background-hover',
isCurrentWorkspace(workspace) && 'bg-secondary-background'
)
"
>
<button
class="flex flex-1 cursor-pointer items-center gap-2 border-none bg-transparent p-0"
@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.type !== '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>
<!-- Delete button - only for team workspaces where user is owner -->
<button
v-if="canDeleteWorkspace(workspace)"
class="flex size-6 cursor-pointer items-center justify-center rounded border-none bg-transparent text-muted-foreground opacity-0 transition-opacity hover:bg-error-background hover:text-error-foreground group-hover:opacity-100"
:title="$t('g.delete')"
@click.stop="handleDeleteWorkspace(workspace)"
>
<i class="pi pi-trash text-xs" />
</button>
</div>
</div>
</template>
</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 { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
import type {
WorkspaceRole,
WorkspaceType
} from '@/platform/workspace/api/workspaceApi'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogService } from '@/services/dialogService'
import { cn } from '@/utils/tailwindUtil'
interface AvailableWorkspace {
id: string
name: string
type: WorkspaceType
role: WorkspaceRole
}
const emit = defineEmits<{
select: [workspace: AvailableWorkspace]
create: []
delete: [workspace: AvailableWorkspace]
}>()
const { t } = useI18n()
const { switchWithConfirmation } = useWorkspaceSwitch()
const { showDeleteWorkspaceDialog } = useDialogService()
const workspaceStore = useTeamWorkspaceStore()
const { workspaceId, workspaces, canCreateWorkspace, isFetchingWorkspaces } =
storeToRefs(workspaceStore)
const availableWorkspaces = computed<AvailableWorkspace[]>(() =>
workspaces.value.map((w) => ({
id: w.id,
name: w.name,
type: w.type,
role: w.role
}))
)
// Workspace store is initialized in router.ts before the app loads
// This component just displays the already-loaded workspace data
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 ''
}
async function handleSelectWorkspace(workspace: AvailableWorkspace) {
const success = await switchWithConfirmation(workspace.id)
if (success) {
emit('select', workspace)
}
}
function handleCreateWorkspace() {
emit('create')
}
function canDeleteWorkspace(workspace: AvailableWorkspace): boolean {
// Can only delete team workspaces where user is owner
return workspace.type === 'team' && workspace.role === 'owner'
}
function handleDeleteWorkspace(workspace: AvailableWorkspace) {
showDeleteWorkspaceDialog({
workspaceId: workspace.id,
workspaceName: workspace.name
})
emit('delete', workspace)
}
</script>

View File

@@ -1,5 +1,6 @@
import { computed, reactive, readonly } from 'vue'
import { isCloud } from '@/platform/distribution/types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { api } from '@/scripts/api'
@@ -95,6 +96,8 @@ export function useFeatureFlags() {
)
},
get teamWorkspacesEnabled() {
if (!isCloud) return false
return (
remoteConfig.value.team_workspaces_enabled ??
api.getServerFeature(ServerFeatureFlag.TEAM_WORKSPACES_ENABLED, false)

View File

@@ -1,6 +1,7 @@
{
"g": {
"user": "User",
"you": "You",
"currentUser": "Current user",
"empty": "Empty",
"noWorkflowsFound": "No workflows found.",
@@ -1264,7 +1265,10 @@
"Scene": "Scene",
"3D": "3D",
"Light": "Light",
"User": "User",
"Profile": "Profile",
"Workspace": "Workspace",
"WorkspacePlan": "Plan & Credits",
"WorkspaceMembers": "Members",
"Credits": "Credits",
"API Nodes": "API Nodes",
"Notification Preferences": "Notification Preferences",
@@ -1997,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",
@@ -2051,6 +2057,9 @@
"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",
"contactOwnerToSubscribe": "Contact the workspace owner to subscribe",
"description": "Choose the best plan for you",
"haveQuestions": "Have questions or wondering about enterprise?",
"contactUs": "Contact us",
@@ -2086,12 +2095,132 @@
"userSettings": {
"title": "My Account Settings",
"accountSettings": "Account settings",
"workspaceSettings": "Workspace settings",
"name": "Name",
"email": "Email",
"provider": "Sign-in Provider",
"notSet": "Not set",
"updatePassword": "Update Password"
},
"workspacePanel": {
"invite": "Invite",
"inviteMember": "Invite member",
"inviteLimitReached": "You've reached the maximum of 50 members",
"tabs": {
"dashboard": "Dashboard",
"planCredits": "Plan & Credits",
"membersCount": "Members ({count})"
},
"dashboard": {
"placeholder": "Dashboard workspace settings"
},
"members": {
"membersCount": "{count}/50 Members",
"pendingInvitesCount": "{count} pending invite | {count} pending invites",
"tabs": {
"active": "Active",
"pendingCount": "Pending ({count})"
},
"columns": {
"inviteDate": "Invite date",
"expiryDate": "Expiry date",
"joinDate": "Join date"
},
"actions": {
"copyLink": "Copy invite link",
"revokeInvite": "Revoke invite",
"removeMember": "Remove member"
},
"noInvites": "No pending invites",
"noMembers": "No members",
"personalWorkspaceMessage": "You can't invite other members to your personal workspace right now. To add members to a workspace,",
"createNewWorkspace": "create a new one."
},
"menu": {
"editWorkspace": "Edit workspace details",
"leaveWorkspace": "Leave Workspace",
"deleteWorkspace": "Delete Workspace",
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first"
},
"editWorkspaceDialog": {
"title": "Edit workspace details",
"nameLabel": "Workspace name",
"save": "Save"
},
"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.",
"messageWithName": "Delete \"{name}\"? Any unused credits or unsaved assets will be lost. This action cannot be undone."
},
"removeMemberDialog": {
"title": "Remove this member?",
"message": "This member will be removed from your workspace. Credits they've used will not be refunded.",
"remove": "Remove member"
},
"revokeInviteDialog": {
"title": "Uninvite this person?",
"message": "This member won't be able to join your workspace anymore. Their invite link will be invalidated.",
"revoke": "Uninvite"
},
"inviteMemberDialog": {
"title": "Invite a person to this workspace",
"message": "Create a shareable invite link to send to someone",
"placeholder": "Enter the person's email",
"createLink": "Create link",
"linkStep": {
"title": "Send this link to the person",
"message": "Make sure their account uses this email.",
"copyLink": "Copy Link",
"done": "Done"
},
"linkCopied": "Copied",
"linkCopyFailed": "Failed to copy link"
},
"createWorkspaceDialog": {
"title": "Create a new workspace",
"message": "Workspaces let members share a single credits pool. You'll become the owner after creating this.",
"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"
},
"workspaceUpdated": {
"title": "Workspace updated",
"message": "Workspace details have been saved."
},
"workspaceDeleted": {
"title": "Workspace deleted",
"message": "The workspace has been permanently deleted."
},
"workspaceLeft": {
"title": "Left workspace",
"message": "You have left the workspace."
},
"failedToUpdateWorkspace": "Failed to update workspace",
"failedToCreateWorkspace": "Failed to create workspace",
"failedToDeleteWorkspace": "Failed to delete workspace",
"failedToLeaveWorkspace": "Failed to leave workspace",
"failedToFetchWorkspaces": "Failed to load workspaces"
}
},
"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)",
@@ -2608,7 +2737,10 @@
"unsavedChanges": {
"title": "Unsaved Changes",
"message": "You have unsaved changes. Do you want to discard them and switch workspaces?"
}
},
"inviteAccepted": "Invite Accepted",
"addedToWorkspace": "You have been added to {workspaceName}",
"inviteFailed": "Failed to Accept Invite"
},
"workspaceAuth": {
"errors": {
@@ -2619,4 +2751,4 @@
"tokenExchangeFailed": "Failed to authenticate with workspace: {error}"
}
}
}
}

View File

@@ -1,5 +1,5 @@
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { api } from '@/scripts/api'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
@@ -19,12 +19,13 @@ export const useSessionCookie = () => {
const createSession = async (): Promise<void> => {
if (!isCloud) return
const { flags } = useFeatureFlags()
try {
const authStore = useFirebaseAuthStore()
let authHeader: Record<string, string>
if (remoteConfig.value.team_workspaces_enabled) {
if (flags.teamWorkspacesEnabled) {
const firebaseToken = await authStore.getIdToken()
if (!firebaseToken) {
console.warn(

View File

@@ -1,670 +0,0 @@
import { createPinia, setActivePinia, storeToRefs } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
useWorkspaceAuthStore,
WorkspaceAuthError
} from '@/stores/workspaceAuthStore'
import { WORKSPACE_STORAGE_KEYS } from './workspaceConstants'
const mockGetIdToken = vi.fn()
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => ({
getIdToken: mockGetIdToken
})
}))
vi.mock('@/scripts/api', () => ({
api: {
apiURL: (route: string) => `https://api.example.com/api${route}`
}
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
const mockRemoteConfig = vi.hoisted(() => ({
value: {
team_workspaces_enabled: true
}
}))
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
remoteConfig: mockRemoteConfig
}))
const mockWorkspace = {
id: 'workspace-123',
name: 'Test Workspace',
type: 'team' as const
}
const mockWorkspaceWithRole = {
...mockWorkspace,
role: 'owner' as const
}
const mockTokenResponse = {
token: 'workspace-token-abc',
expires_at: new Date(Date.now() + 3600 * 1000).toISOString(),
workspace: mockWorkspace,
role: 'owner' as const,
permissions: ['owner:*']
}
describe('useWorkspaceAuthStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
vi.useFakeTimers()
sessionStorage.clear()
})
afterEach(() => {
vi.useRealTimers()
})
describe('initial state', () => {
it('has correct initial state values', () => {
const store = useWorkspaceAuthStore()
const {
currentWorkspace,
workspaceToken,
isAuthenticated,
isLoading,
error
} = storeToRefs(store)
expect(currentWorkspace.value).toBeNull()
expect(workspaceToken.value).toBeNull()
expect(isAuthenticated.value).toBe(false)
expect(isLoading.value).toBe(false)
expect(error.value).toBeNull()
})
})
describe('initializeFromSession', () => {
it('returns true and populates state when valid session data exists', () => {
const futureExpiry = Date.now() + 3600 * 1000
sessionStorage.setItem(
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE,
JSON.stringify(mockWorkspaceWithRole)
)
sessionStorage.setItem(WORKSPACE_STORAGE_KEYS.TOKEN, 'valid-token')
sessionStorage.setItem(
WORKSPACE_STORAGE_KEYS.EXPIRES_AT,
futureExpiry.toString()
)
const store = useWorkspaceAuthStore()
const { currentWorkspace, workspaceToken } = storeToRefs(store)
const result = store.initializeFromSession()
expect(result).toBe(true)
expect(currentWorkspace.value).toEqual(mockWorkspaceWithRole)
expect(workspaceToken.value).toBe('valid-token')
})
it('returns false when sessionStorage is empty', () => {
const store = useWorkspaceAuthStore()
const result = store.initializeFromSession()
expect(result).toBe(false)
})
it('returns false and clears storage when token is expired', () => {
const pastExpiry = Date.now() - 1000
sessionStorage.setItem(
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE,
JSON.stringify(mockWorkspaceWithRole)
)
sessionStorage.setItem(WORKSPACE_STORAGE_KEYS.TOKEN, 'expired-token')
sessionStorage.setItem(
WORKSPACE_STORAGE_KEYS.EXPIRES_AT,
pastExpiry.toString()
)
const store = useWorkspaceAuthStore()
const result = store.initializeFromSession()
expect(result).toBe(false)
expect(
sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE)
).toBeNull()
expect(sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.TOKEN)).toBeNull()
expect(
sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT)
).toBeNull()
})
it('returns false and clears storage when data is malformed', () => {
sessionStorage.setItem(
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE,
'invalid-json{'
)
sessionStorage.setItem(WORKSPACE_STORAGE_KEYS.TOKEN, 'some-token')
sessionStorage.setItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT, 'not-a-number')
const store = useWorkspaceAuthStore()
const result = store.initializeFromSession()
expect(result).toBe(false)
expect(
sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE)
).toBeNull()
expect(sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.TOKEN)).toBeNull()
expect(
sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT)
).toBeNull()
})
it('returns false when partial session data exists (missing token)', () => {
sessionStorage.setItem(
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE,
JSON.stringify(mockWorkspaceWithRole)
)
sessionStorage.setItem(
WORKSPACE_STORAGE_KEYS.EXPIRES_AT,
(Date.now() + 3600 * 1000).toString()
)
const store = useWorkspaceAuthStore()
const result = store.initializeFromSession()
expect(result).toBe(false)
})
})
describe('switchWorkspace', () => {
it('successfully exchanges Firebase token for workspace token', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
)
const store = useWorkspaceAuthStore()
const { currentWorkspace, workspaceToken, isAuthenticated } =
storeToRefs(store)
await store.switchWorkspace('workspace-123')
expect(currentWorkspace.value).toEqual(mockWorkspaceWithRole)
expect(workspaceToken.value).toBe('workspace-token-abc')
expect(isAuthenticated.value).toBe(true)
})
it('stores workspace data in sessionStorage', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
)
const store = useWorkspaceAuthStore()
await store.switchWorkspace('workspace-123')
expect(
sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE)
).toBe(JSON.stringify(mockWorkspaceWithRole))
expect(sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.TOKEN)).toBe(
'workspace-token-abc'
)
expect(
sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT)
).toBeTruthy()
})
it('sets isLoading to true during operation', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
let resolveResponse: (value: unknown) => void
const responsePromise = new Promise((resolve) => {
resolveResponse = resolve
})
vi.stubGlobal('fetch', vi.fn().mockReturnValue(responsePromise))
const store = useWorkspaceAuthStore()
const { isLoading } = storeToRefs(store)
const switchPromise = store.switchWorkspace('workspace-123')
expect(isLoading.value).toBe(true)
resolveResponse!({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
await switchPromise
expect(isLoading.value).toBe(false)
})
it('throws WorkspaceAuthError with code NOT_AUTHENTICATED when Firebase token unavailable', async () => {
mockGetIdToken.mockResolvedValue(undefined)
const store = useWorkspaceAuthStore()
const { error } = storeToRefs(store)
await expect(store.switchWorkspace('workspace-123')).rejects.toThrow(
WorkspaceAuthError
)
expect(error.value).toBeInstanceOf(WorkspaceAuthError)
expect((error.value as WorkspaceAuthError).code).toBe('NOT_AUTHENTICATED')
})
it('throws WorkspaceAuthError with code ACCESS_DENIED on 403 response', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 403,
statusText: 'Forbidden',
json: () => Promise.resolve({ message: 'Access denied' })
})
)
const store = useWorkspaceAuthStore()
const { error } = storeToRefs(store)
await expect(store.switchWorkspace('workspace-123')).rejects.toThrow(
WorkspaceAuthError
)
expect(error.value).toBeInstanceOf(WorkspaceAuthError)
expect((error.value as WorkspaceAuthError).code).toBe('ACCESS_DENIED')
})
it('throws WorkspaceAuthError with code WORKSPACE_NOT_FOUND on 404 response', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 404,
statusText: 'Not Found',
json: () => Promise.resolve({ message: 'Workspace not found' })
})
)
const store = useWorkspaceAuthStore()
const { error } = storeToRefs(store)
await expect(store.switchWorkspace('workspace-123')).rejects.toThrow(
WorkspaceAuthError
)
expect(error.value).toBeInstanceOf(WorkspaceAuthError)
expect((error.value as WorkspaceAuthError).code).toBe(
'WORKSPACE_NOT_FOUND'
)
})
it('throws WorkspaceAuthError with code INVALID_FIREBASE_TOKEN on 401 response', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 401,
statusText: 'Unauthorized',
json: () => Promise.resolve({ message: 'Invalid token' })
})
)
const store = useWorkspaceAuthStore()
const { error } = storeToRefs(store)
await expect(store.switchWorkspace('workspace-123')).rejects.toThrow(
WorkspaceAuthError
)
expect(error.value).toBeInstanceOf(WorkspaceAuthError)
expect((error.value as WorkspaceAuthError).code).toBe(
'INVALID_FIREBASE_TOKEN'
)
})
it('throws WorkspaceAuthError with code TOKEN_EXCHANGE_FAILED on other errors', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: () => Promise.resolve({ message: 'Server error' })
})
)
const store = useWorkspaceAuthStore()
const { error } = storeToRefs(store)
await expect(store.switchWorkspace('workspace-123')).rejects.toThrow(
WorkspaceAuthError
)
expect(error.value).toBeInstanceOf(WorkspaceAuthError)
expect((error.value as WorkspaceAuthError).code).toBe(
'TOKEN_EXCHANGE_FAILED'
)
})
it('sends correct request to API', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
await store.switchWorkspace('workspace-123')
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/api/auth/token',
{
method: 'POST',
headers: {
Authorization: 'Bearer firebase-token-xyz',
'Content-Type': 'application/json'
},
body: JSON.stringify({ workspace_id: 'workspace-123' })
}
)
})
})
describe('clearWorkspaceContext', () => {
it('clears all state refs', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
)
const store = useWorkspaceAuthStore()
const { currentWorkspace, workspaceToken, error, isAuthenticated } =
storeToRefs(store)
await store.switchWorkspace('workspace-123')
expect(isAuthenticated.value).toBe(true)
store.clearWorkspaceContext()
expect(currentWorkspace.value).toBeNull()
expect(workspaceToken.value).toBeNull()
expect(error.value).toBeNull()
expect(isAuthenticated.value).toBe(false)
})
it('clears sessionStorage', async () => {
sessionStorage.setItem(
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE,
JSON.stringify(mockWorkspaceWithRole)
)
sessionStorage.setItem(WORKSPACE_STORAGE_KEYS.TOKEN, 'some-token')
sessionStorage.setItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT, '12345')
const store = useWorkspaceAuthStore()
store.clearWorkspaceContext()
expect(
sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE)
).toBeNull()
expect(sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.TOKEN)).toBeNull()
expect(
sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT)
).toBeNull()
})
})
describe('getWorkspaceAuthHeader', () => {
it('returns null when no workspace token', () => {
const store = useWorkspaceAuthStore()
const header = store.getWorkspaceAuthHeader()
expect(header).toBeNull()
})
it('returns proper Authorization header when workspace token exists', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
)
const store = useWorkspaceAuthStore()
await store.switchWorkspace('workspace-123')
const header = store.getWorkspaceAuthHeader()
expect(header).toEqual({
Authorization: 'Bearer workspace-token-abc'
})
})
})
describe('token refresh scheduling', () => {
it('schedules token refresh 5 minutes before expiry', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const expiresInMs = 3600 * 1000
const tokenResponseWithFutureExpiry = {
...mockTokenResponse,
expires_at: new Date(Date.now() + expiresInMs).toISOString()
}
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(tokenResponseWithFutureExpiry)
})
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
await store.switchWorkspace('workspace-123')
expect(mockFetch).toHaveBeenCalledTimes(1)
const refreshBufferMs = 5 * 60 * 1000
const refreshDelay = expiresInMs - refreshBufferMs
vi.advanceTimersByTime(refreshDelay - 1)
expect(mockFetch).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
expect(mockFetch).toHaveBeenCalledTimes(2)
})
it('clears context when refresh fails with ACCESS_DENIED', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const expiresInMs = 3600 * 1000
const tokenResponseWithFutureExpiry = {
...mockTokenResponse,
expires_at: new Date(Date.now() + expiresInMs).toISOString()
}
const mockFetch = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(tokenResponseWithFutureExpiry)
})
.mockResolvedValueOnce({
ok: false,
status: 403,
statusText: 'Forbidden',
json: () => Promise.resolve({ message: 'Access denied' })
})
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
const { currentWorkspace, workspaceToken } = storeToRefs(store)
await store.switchWorkspace('workspace-123')
expect(workspaceToken.value).toBe('workspace-token-abc')
const refreshBufferMs = 5 * 60 * 1000
const refreshDelay = expiresInMs - refreshBufferMs
vi.advanceTimersByTime(refreshDelay)
await vi.waitFor(() => {
expect(currentWorkspace.value).toBeNull()
})
expect(workspaceToken.value).toBeNull()
})
})
describe('refreshToken', () => {
it('does nothing when no current workspace', async () => {
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
await store.refreshToken()
expect(mockFetch).not.toHaveBeenCalled()
})
it('refreshes token for current workspace', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
const { workspaceToken } = storeToRefs(store)
await store.switchWorkspace('workspace-123')
expect(mockFetch).toHaveBeenCalledTimes(1)
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
...mockTokenResponse,
token: 'refreshed-token'
})
})
await store.refreshToken()
expect(mockFetch).toHaveBeenCalledTimes(2)
expect(workspaceToken.value).toBe('refreshed-token')
})
})
describe('isAuthenticated computed', () => {
it('returns true when both workspace and token are present', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
)
const store = useWorkspaceAuthStore()
const { isAuthenticated } = storeToRefs(store)
await store.switchWorkspace('workspace-123')
expect(isAuthenticated.value).toBe(true)
})
it('returns false when workspace is null', () => {
const store = useWorkspaceAuthStore()
const { isAuthenticated } = storeToRefs(store)
expect(isAuthenticated.value).toBe(false)
})
it('returns false when currentWorkspace is set but workspaceToken is null', async () => {
mockGetIdToken.mockResolvedValue(null)
const store = useWorkspaceAuthStore()
const { currentWorkspace, workspaceToken, isAuthenticated } =
storeToRefs(store)
currentWorkspace.value = mockWorkspaceWithRole
workspaceToken.value = null
expect(isAuthenticated.value).toBe(false)
})
})
describe('feature flag disabled', () => {
beforeEach(() => {
mockRemoteConfig.value.team_workspaces_enabled = false
})
afterEach(() => {
mockRemoteConfig.value.team_workspaces_enabled = true
})
it('initializeFromSession returns false when flag disabled', () => {
const futureExpiry = Date.now() + 3600 * 1000
sessionStorage.setItem(
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE,
JSON.stringify(mockWorkspaceWithRole)
)
sessionStorage.setItem(WORKSPACE_STORAGE_KEYS.TOKEN, 'valid-token')
sessionStorage.setItem(
WORKSPACE_STORAGE_KEYS.EXPIRES_AT,
futureExpiry.toString()
)
const store = useWorkspaceAuthStore()
const { currentWorkspace, workspaceToken } = storeToRefs(store)
const result = store.initializeFromSession()
expect(result).toBe(false)
expect(currentWorkspace.value).toBeNull()
expect(workspaceToken.value).toBeNull()
})
it('switchWorkspace is a no-op when flag disabled', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
const { currentWorkspace, workspaceToken, isLoading } = storeToRefs(store)
await store.switchWorkspace('workspace-123')
expect(mockFetch).not.toHaveBeenCalled()
expect(currentWorkspace.value).toBeNull()
expect(workspaceToken.value).toBeNull()
expect(isLoading.value).toBe(false)
})
})
})

View File

@@ -1,22 +1,22 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
import type { WorkspaceWithRole } from '@/platform/auth/workspace/workspaceTypes'
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
const mockSwitchWorkspace = vi.hoisted(() => vi.fn())
const mockCurrentWorkspace = vi.hoisted(() => ({
const mockActiveWorkspace = vi.hoisted(() => ({
value: null as WorkspaceWithRole | null
}))
vi.mock('@/stores/workspaceAuthStore', () => ({
useWorkspaceAuthStore: () => ({
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
switchWorkspace: mockSwitchWorkspace
})
}))
vi.mock('pinia', () => ({
storeToRefs: () => ({
currentWorkspace: mockCurrentWorkspace
activeWorkspace: mockActiveWorkspace
})
}))
@@ -46,19 +46,16 @@ vi.mock('vue-i18n', () => ({
})
}))
const mockReload = vi.fn()
describe('useWorkspaceSwitch', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCurrentWorkspace.value = {
mockActiveWorkspace.value = {
id: 'workspace-1',
name: 'Test Workspace',
type: 'personal',
role: 'owner'
}
mockModifiedWorkflows.length = 0
vi.stubGlobal('location', { reload: mockReload })
})
afterEach(() => {
@@ -109,7 +106,6 @@ describe('useWorkspaceSwitch', () => {
expect(result).toBe(true)
expect(mockConfirm).not.toHaveBeenCalled()
expect(mockSwitchWorkspace).toHaveBeenCalledWith('workspace-2')
expect(mockReload).toHaveBeenCalled()
})
it('shows confirmation dialog when there are unsaved changes', async () => {
@@ -136,10 +132,9 @@ describe('useWorkspaceSwitch', () => {
expect(result).toBe(false)
expect(mockSwitchWorkspace).not.toHaveBeenCalled()
expect(mockReload).not.toHaveBeenCalled()
})
it('calls switchWorkspace and reloads page after user confirms', async () => {
it('calls switchWorkspace after user confirms', async () => {
mockModifiedWorkflows.push({ isModified: true })
mockConfirm.mockResolvedValue(true)
mockSwitchWorkspace.mockResolvedValue(undefined)
@@ -149,7 +144,6 @@ describe('useWorkspaceSwitch', () => {
expect(result).toBe(true)
expect(mockSwitchWorkspace).toHaveBeenCalledWith('workspace-2')
expect(mockReload).toHaveBeenCalled()
})
it('returns false if switchWorkspace throws an error', async () => {
@@ -160,7 +154,6 @@ describe('useWorkspaceSwitch', () => {
const result = await switchWithConfirmation('workspace-2')
expect(result).toBe(false)
expect(mockReload).not.toHaveBeenCalled()
})
})
})

View File

@@ -2,13 +2,13 @@ import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogService } from '@/services/dialogService'
import { useWorkspaceAuthStore } from '@/stores/workspaceAuthStore'
export function useWorkspaceSwitch() {
const { t } = useI18n()
const workspaceAuthStore = useWorkspaceAuthStore()
const { currentWorkspace } = storeToRefs(workspaceAuthStore)
const workspaceStore = useTeamWorkspaceStore()
const { activeWorkspace } = storeToRefs(workspaceStore)
const workflowStore = useWorkflowStore()
const dialogService = useDialogService()
@@ -17,7 +17,7 @@ export function useWorkspaceSwitch() {
}
async function switchWithConfirmation(workspaceId: string): Promise<boolean> {
if (currentWorkspace.value?.id === workspaceId) {
if (activeWorkspace.value?.id === workspaceId) {
return true
}
@@ -34,8 +34,8 @@ export function useWorkspaceSwitch() {
}
try {
await workspaceAuthStore.switchWorkspace(workspaceId)
window.location.reload()
await workspaceStore.switchWorkspace(workspaceId)
// Note: switchWorkspace triggers page reload internally
return true
} catch {
return false

View File

@@ -1,7 +1,10 @@
export const WORKSPACE_STORAGE_KEYS = {
// sessionStorage keys (cleared on browser close)
CURRENT_WORKSPACE: 'Comfy.Workspace.Current',
TOKEN: 'Comfy.Workspace.Token',
EXPIRES_AT: 'Comfy.Workspace.ExpiresAt'
EXPIRES_AT: 'Comfy.Workspace.ExpiresAt',
// localStorage key (persists across browser sessions)
LAST_WORKSPACE_ID: 'Comfy.Workspace.LastWorkspaceId'
} as const
export const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000

View File

@@ -1,6 +0,0 @@
export interface WorkspaceWithRole {
id: string
name: string
type: 'personal' | 'team'
role: 'owner' | 'member'
}

View File

@@ -90,6 +90,31 @@ vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
}))
}))
// Mock toast store (needed by useWorkspace -> useInviteUrlLoader)
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => ({
add: vi.fn()
}))
}))
// Mock useWorkspace composable
vi.mock('@/platform/workspace/composables/useWorkspace', () => ({
useWorkspace: vi.fn(() => ({
workspaceName: { value: 'Test Workspace' },
workspaceId: { value: 'test-workspace-id' },
workspaceType: { value: 'personal' },
workspaceRole: { value: 'owner' },
isPersonalWorkspace: { value: true },
isWorkspaceSubscribed: { value: true },
subscriptionPlan: { value: null },
permissions: { value: { canManageSubscription: true } },
availableWorkspaces: { value: [] },
fetchWorkspaces: vi.fn(),
switchWorkspace: vi.fn(),
subscribeWorkspace: vi.fn()
}))
}))
// Create i18n instance for testing
const i18n = createI18n({
legacy: false,

View File

@@ -17,208 +17,10 @@
</div>
</div>
<div class="grow overflow-auto">
<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 }}
</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>
<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>
<SubscribeButton
v-else
:label="$t('subscription.subscribeNow')"
size="sm"
:fluid="false"
class="text-xs"
@subscribed="handleRefresh"
/>
</div>
</div>
<div class="flex flex-col lg:flex-row gap-6 pt-9">
<div class="flex flex-col shrink-0">
<div class="flex flex-col gap-3">
<div
:class="
cn(
'relative flex flex-col gap-6 rounded-2xl p-5',
'bg-modal-panel-background'
)
"
>
<Button
variant="muted-textonly"
size="icon-sm"
class="absolute top-4 right-4"
:loading="isLoadingBalance"
@click="handleRefresh"
>
<i class="pi pi-sync text-text-secondary text-sm" />
</Button>
<div class="flex flex-col gap-2">
<div class="text-sm text-muted">
{{ $t('subscription.totalCredits') }}
</div>
<Skeleton
v-if="isLoadingBalance"
width="8rem"
height="2rem"
/>
<div v-else class="text-2xl font-bold">
{{ totalCredits }}
</div>
</div>
<!-- Credit Breakdown -->
<table class="text-sm text-muted">
<tbody>
<tr>
<td class="pr-4 font-bold text-left align-middle">
<Skeleton
v-if="isLoadingBalance"
width="5rem"
height="1rem"
/>
<span v-else>{{ includedCreditsDisplay }}</span>
</td>
<td class="align-middle" :title="creditsRemainingLabel">
{{ creditsRemainingLabel }}
</td>
</tr>
<tr>
<td class="pr-4 font-bold text-left align-middle">
<Skeleton
v-if="isLoadingBalance"
width="3rem"
height="1rem"
/>
<span v-else>{{ prepaidCredits }}</span>
</td>
<td
class="align-middle"
:title="$t('subscription.creditsYouveAdded')"
>
{{ $t('subscription.creditsYouveAdded') }}
</td>
</tr>
</tbody>
</table>
<div class="flex items-center justify-between">
<a
:href="usageHistoryUrl"
target="_blank"
rel="noopener noreferrer"
class="text-sm underline text-center text-muted"
>
{{ $t('subscription.viewUsageHistory') }}
</a>
<Button
v-if="isActiveSubscription"
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"
>
{{ $t('subscription.addCredits') }}
</Button>
</div>
</div>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="text-sm text-text-primary">
{{ $t('subscription.yourPlanIncludes') }}
</div>
<div class="flex flex-col gap-0">
<div
v-for="benefit in tierBenefits"
:key="benefit.key"
class="flex items-center gap-2 py-2"
>
<i
v-if="benefit.type === 'feature'"
class="pi pi-check text-xs text-text-primary"
/>
<span
v-else-if="benefit.type === 'metric' && benefit.value"
class="text-sm font-normal whitespace-nowrap text-text-primary"
>
{{ benefit.value }}
</span>
<span class="text-sm text-muted">
{{ benefit.label }}
</span>
</div>
</div>
</div>
</div>
</div>
<!-- View More Details - Outside main content -->
<div class="flex items-center gap-2 py-4">
<i class="pi pi-external-link text-muted"></i>
<a
href="https://www.comfy.org/cloud/pricing"
target="_blank"
rel="noopener noreferrer"
class="text-sm underline hover:opacity-80 text-muted"
>
{{ $t('subscription.viewMoreDetailsPlans') }}
</a>
</div>
</div>
<!-- Workspace mode: workspace-aware subscription content -->
<SubscriptionPanelContentWorkspace v-if="teamWorkspacesEnabled" />
<!-- Legacy mode: user-level subscription content -->
<SubscriptionPanelContentLegacy v-else />
<div
class="flex items-center justify-between border-t border-interface-stroke pt-3"
@@ -265,171 +67,32 @@
</template>
<script setup lang="ts">
import Skeleton from 'primevue/skeleton'
import TabPanel from 'primevue/tabpanel'
import { computed, onBeforeUnmount, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { defineAsyncComponent } from 'vue'
import CloudBadge from '@/components/topbar/CloudBadge.vue'
import Button from '@/components/ui/button/Button.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import SubscriptionPanelContentLegacy from '@/platform/cloud/subscription/components/SubscriptionPanelContentLegacy.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import {
DEFAULT_TIER_KEY,
TIER_TO_KEY,
getTierCredits,
getTierFeatures,
getTierPrice
} from '@/platform/cloud/subscription/constants/tierPricing'
import { cn } from '@/utils/tailwindUtil'
import { isCloud } from '@/platform/distribution/types'
const SubscriptionPanelContentWorkspace = defineAsyncComponent(
() =>
import('@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue')
)
const { flags } = useFeatureFlags()
const teamWorkspacesEnabled = isCloud && flags.teamWorkspacesEnabled
const { buildDocsUrl, docsPaths } = useExternalLink()
const authActions = useFirebaseAuthActions()
const { t, n } = useI18n()
const {
isActiveSubscription,
isCancelled,
formattedRenewalDate,
formattedEndDate,
subscriptionTier,
subscriptionTierName,
subscriptionStatus,
isYearlySubscription,
handleInvoiceHistory
} = useSubscription()
const { isActiveSubscription, handleInvoiceHistory } = useSubscription()
const { show: showSubscriptionDialog } = useSubscriptionDialog()
const tierKey = computed(() => {
const tier = subscriptionTier.value
if (!tier) return DEFAULT_TIER_KEY
return TIER_TO_KEY[tier] ?? DEFAULT_TIER_KEY
})
const tierPrice = computed(() =>
getTierPrice(tierKey.value, isYearlySubscription.value)
)
const usageHistoryUrl = computed(
() => `${getComfyPlatformBaseUrl()}/profile/usage`
)
const refillsDate = computed(() => {
if (!subscriptionStatus.value?.renewal_date) return ''
const date = new Date(subscriptionStatus.value.renewal_date)
const day = String(date.getDate()).padStart(2, '0')
const month = String(date.getMonth() + 1).padStart(2, '0')
const year = String(date.getFullYear()).slice(-2)
return `${month}/${day}/${year}`
})
const creditsRemainingLabel = computed(() =>
isYearlySubscription.value
? t('subscription.creditsRemainingThisYear', {
date: refillsDate.value
})
: t('subscription.creditsRemainingThisMonth', {
date: refillsDate.value
})
)
const planTotalCredits = computed(() => {
const credits = getTierCredits(tierKey.value)
const total = isYearlySubscription.value ? credits * 12 : credits
return n(total)
})
const includedCreditsDisplay = computed(
() => `${monthlyBonusCredits.value} / ${planTotalCredits.value}`
)
// Tier benefits for v-for loop
type BenefitType = 'metric' | 'feature'
interface Benefit {
key: string
type: BenefitType
label: string
value?: string
}
const tierBenefits = computed((): Benefit[] => {
const key = tierKey.value
const benefits: Benefit[] = [
{
key: 'maxDuration',
type: 'metric',
value: t(`subscription.maxDuration.${key}`),
label: t('subscription.maxDurationLabel')
},
{
key: 'gpu',
type: 'feature',
label: t('subscription.gpuLabel')
},
{
key: 'addCredits',
type: 'feature',
label: t('subscription.addCreditsLabel')
}
]
if (getTierFeatures(key).customLoRAs) {
benefits.push({
key: 'customLoRAs',
type: 'feature',
label: t('subscription.customLoRAsLabel')
})
}
return benefits
})
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
useSubscriptionCredits()
const {
isLoadingSupport,
handleAddApiCredits,
handleMessageSupport,
handleRefresh,
handleLearnMoreClick
} = useSubscriptionActions()
// Focus-based polling: refresh balance when user returns from Stripe checkout
const PENDING_TOPUP_KEY = 'pending_topup_timestamp'
const TOPUP_EXPIRY_MS = 5 * 60 * 1000 // 5 minutes
function handleWindowFocus() {
const timestampStr = localStorage.getItem(PENDING_TOPUP_KEY)
if (!timestampStr) return
const timestamp = parseInt(timestampStr, 10)
// Clear expired tracking (older than 5 minutes)
if (Date.now() - timestamp > TOPUP_EXPIRY_MS) {
localStorage.removeItem(PENDING_TOPUP_KEY)
return
}
// Refresh and clear tracking to prevent repeated calls
void handleRefresh()
localStorage.removeItem(PENDING_TOPUP_KEY)
}
onMounted(() => {
window.addEventListener('focus', handleWindowFocus)
})
onBeforeUnmount(() => {
window.removeEventListener('focus', handleWindowFocus)
})
const { isLoadingSupport, handleMessageSupport, handleLearnMoreClick } =
useSubscriptionActions()
const handleOpenPartnerNodesInfo = () => {
window.open(
@@ -438,9 +101,3 @@ const handleOpenPartnerNodesInfo = () => {
)
}
</script>
<style scoped>
:deep(.bg-comfy-menu-secondary) {
background-color: transparent;
}
</style>

View File

@@ -0,0 +1,357 @@
<template>
<div class="grow overflow-auto">
<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 }}
</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>
<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>
<SubscribeButton
v-if="!isActiveSubscription"
:label="$t('subscription.subscribeNow')"
size="sm"
:fluid="false"
class="text-xs"
@subscribed="handleRefresh"
/>
</div>
</div>
<div class="flex flex-col lg:flex-row gap-6 pt-9">
<div class="flex flex-col shrink-0">
<div class="flex flex-col gap-3">
<div
:class="
cn(
'relative flex flex-col gap-6 rounded-2xl p-5',
'bg-modal-panel-background'
)
"
>
<Button
variant="muted-textonly"
size="icon-sm"
class="absolute top-4 right-4"
:loading="isLoadingBalance"
@click="handleRefresh"
>
<i class="pi pi-sync text-text-secondary text-sm" />
</Button>
<div class="flex flex-col gap-2">
<div class="text-sm text-muted">
{{ $t('subscription.totalCredits') }}
</div>
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
<div v-else class="text-2xl font-bold">
{{ totalCredits }}
</div>
</div>
<!-- Credit Breakdown -->
<table class="text-sm text-muted">
<tbody>
<tr>
<td class="pr-4 font-bold text-left align-middle">
<Skeleton
v-if="isLoadingBalance"
width="5rem"
height="1rem"
/>
<span v-else>{{ includedCreditsDisplay }}</span>
</td>
<td class="align-middle" :title="creditsRemainingLabel">
{{ creditsRemainingLabel }}
</td>
</tr>
<tr>
<td class="pr-4 font-bold text-left align-middle">
<Skeleton
v-if="isLoadingBalance"
width="3rem"
height="1rem"
/>
<span v-else>{{ prepaidCredits }}</span>
</td>
<td
class="align-middle"
:title="$t('subscription.creditsYouveAdded')"
>
{{ $t('subscription.creditsYouveAdded') }}
</td>
</tr>
</tbody>
</table>
<div class="flex items-center justify-between">
<a
href="https://platform.comfy.org/profile/usage"
target="_blank"
rel="noopener noreferrer"
class="text-sm underline text-center text-muted"
>
{{ $t('subscription.viewUsageHistory') }}
</a>
<Button
v-if="isActiveSubscription"
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"
>
{{ $t('subscription.addCredits') }}
</Button>
</div>
</div>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="text-sm text-text-primary">
{{ $t('subscription.yourPlanIncludes') }}
</div>
<div class="flex flex-col gap-0">
<div
v-for="benefit in tierBenefits"
:key="benefit.key"
class="flex items-center gap-2 py-2"
>
<i
v-if="benefit.type === 'feature'"
class="pi pi-check text-xs text-text-primary"
/>
<span
v-else-if="benefit.type === 'metric' && benefit.value"
class="text-sm font-normal whitespace-nowrap text-text-primary"
>
{{ benefit.value }}
</span>
<span class="text-sm text-muted">
{{ benefit.label }}
</span>
</div>
</div>
</div>
</div>
</div>
<!-- View More Details - Outside main content -->
<div class="flex items-center gap-2 py-4">
<i class="pi pi-external-link text-muted"></i>
<a
href="https://www.comfy.org/cloud/pricing"
target="_blank"
rel="noopener noreferrer"
class="text-sm underline hover:opacity-80 text-muted"
>
{{ $t('subscription.viewMoreDetailsPlans') }}
</a>
</div>
</div>
</template>
<script setup lang="ts">
import Skeleton from 'primevue/skeleton'
import { computed, onBeforeUnmount, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import {
DEFAULT_TIER_KEY,
TIER_TO_KEY,
getTierCredits,
getTierFeatures,
getTierPrice
} from '@/platform/cloud/subscription/constants/tierPricing'
import { cn } from '@/utils/tailwindUtil'
const authActions = useFirebaseAuthActions()
const { t, n } = useI18n()
const {
isActiveSubscription,
isCancelled,
formattedRenewalDate,
formattedEndDate,
subscriptionTier,
subscriptionTierName,
subscriptionStatus,
isYearlySubscription
} = useSubscription()
const { show: showSubscriptionDialog } = useSubscriptionDialog()
const tierKey = computed(() => {
const tier = subscriptionTier.value
if (!tier) return DEFAULT_TIER_KEY
return TIER_TO_KEY[tier] ?? DEFAULT_TIER_KEY
})
const tierPrice = computed(() =>
getTierPrice(tierKey.value, isYearlySubscription.value)
)
const refillsDate = computed(() => {
if (!subscriptionStatus.value?.renewal_date) return ''
const date = new Date(subscriptionStatus.value.renewal_date)
const day = String(date.getDate()).padStart(2, '0')
const month = String(date.getMonth() + 1).padStart(2, '0')
const year = String(date.getFullYear()).slice(-2)
return `${month}/${day}/${year}`
})
const creditsRemainingLabel = computed(() =>
isYearlySubscription.value
? t('subscription.creditsRemainingThisYear', {
date: refillsDate.value
})
: t('subscription.creditsRemainingThisMonth', {
date: refillsDate.value
})
)
const planTotalCredits = computed(() => {
const credits = getTierCredits(tierKey.value)
const total = isYearlySubscription.value ? credits * 12 : credits
return n(total)
})
const includedCreditsDisplay = computed(
() => `${monthlyBonusCredits.value} / ${planTotalCredits.value}`
)
// Tier benefits for v-for loop
type BenefitType = 'metric' | 'feature'
interface Benefit {
key: string
type: BenefitType
label: string
value?: string
}
const tierBenefits = computed((): Benefit[] => {
const key = tierKey.value
const benefits: Benefit[] = [
{
key: 'maxDuration',
type: 'metric',
value: t(`subscription.maxDuration.${key}`),
label: t('subscription.maxDurationLabel')
},
{
key: 'gpu',
type: 'feature',
label: t('subscription.gpuLabel')
},
{
key: 'addCredits',
type: 'feature',
label: t('subscription.addCreditsLabel')
}
]
if (getTierFeatures(key).customLoRAs) {
benefits.push({
key: 'customLoRAs',
type: 'feature',
label: t('subscription.customLoRAsLabel')
})
}
return benefits
})
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
useSubscriptionCredits()
const { handleAddApiCredits, handleRefresh } = useSubscriptionActions()
// Focus-based polling: refresh balance when user returns from Stripe checkout
const PENDING_TOPUP_KEY = 'pending_topup_timestamp'
const TOPUP_EXPIRY_MS = 5 * 60 * 1000 // 5 minutes
function handleWindowFocus() {
const timestampStr = localStorage.getItem(PENDING_TOPUP_KEY)
if (!timestampStr) return
const timestamp = parseInt(timestampStr, 10)
// Clear expired tracking (older than 5 minutes)
if (Date.now() - timestamp > TOPUP_EXPIRY_MS) {
localStorage.removeItem(PENDING_TOPUP_KEY)
return
}
// Refresh and clear tracking to prevent repeated calls
void handleRefresh()
localStorage.removeItem(PENDING_TOPUP_KEY)
}
onMounted(() => {
window.addEventListener('focus', handleWindowFocus)
})
onBeforeUnmount(() => {
window.removeEventListener('focus', handleWindowFocus)
})
</script>
<style scoped>
:deep(.bg-comfy-menu-secondary) {
background-color: transparent;
}
</style>

View File

@@ -0,0 +1,435 @@
<template>
<div class="grow overflow-auto">
<div class="rounded-2xl border border-interface-stroke p-6">
<div>
<div class="flex items-center justify-between gap-2">
<!-- 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>
<Button
variant="primary"
class="ml-auto rounded-lg px-4 py-2 text-sm font-normal"
@click="handleSubscribeWorkspace"
>
{{ $t('subscription.subscribeNow') }}
</Button>
</template>
<!-- MEMBER View - read-only, no subscription data yet -->
<template v-else-if="isMemberView">
<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.contactOwnerToSubscribe') }}
</div>
</div>
</template>
<!-- Normal Subscribed State (Owner with subscription) -->
<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>
<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>
</template>
</div>
</div>
<div class="flex flex-col lg:flex-row gap-6 pt-9">
<div class="flex flex-col shrink-0">
<div class="flex flex-col gap-3">
<div
:class="
cn(
'relative flex flex-col gap-6 rounded-2xl p-5',
'bg-modal-panel-background'
)
"
>
<Button
variant="muted-textonly"
size="icon-sm"
class="absolute top-4 right-4"
:loading="isLoadingBalance"
@click="handleRefresh"
>
<i class="pi pi-sync text-text-secondary text-sm" />
</Button>
<div class="flex flex-col gap-2">
<div class="text-sm text-muted">
{{ $t('subscription.totalCredits') }}
</div>
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
<div v-else class="text-2xl font-bold">
{{ showZeroState ? '0' : totalCredits }}
</div>
</div>
<!-- Credit Breakdown -->
<table class="text-sm text-muted">
<tbody>
<tr>
<td class="pr-4 font-bold text-left align-middle">
<Skeleton
v-if="isLoadingBalance"
width="5rem"
height="1rem"
/>
<span v-else>{{
showZeroState ? '0 / 0' : includedCreditsDisplay
}}</span>
</td>
<td class="align-middle" :title="creditsRemainingLabel">
{{ creditsRemainingLabel }}
</td>
</tr>
<tr>
<td class="pr-4 font-bold text-left align-middle">
<Skeleton
v-if="isLoadingBalance"
width="3rem"
height="1rem"
/>
<span v-else>{{
showZeroState ? '0' : prepaidCredits
}}</span>
</td>
<td
class="align-middle"
:title="$t('subscription.creditsYouveAdded')"
>
{{ $t('subscription.creditsYouveAdded') }}
</td>
</tr>
</tbody>
</table>
<div class="flex items-center justify-between">
<a
href="https://platform.comfy.org/profile/usage"
target="_blank"
rel="noopener noreferrer"
class="text-sm underline text-center text-muted"
>
{{ $t('subscription.viewUsageHistory') }}
</a>
<Button
v-if="isActiveSubscription && !showZeroState"
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"
>
{{ $t('subscription.addCredits') }}
</Button>
</div>
</div>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="text-sm text-text-primary">
{{ $t('subscription.yourPlanIncludes') }}
</div>
<div class="flex flex-col gap-0">
<div
v-for="benefit in tierBenefits"
:key="benefit.key"
class="flex items-center gap-2 py-2"
>
<i
v-if="benefit.type === 'feature'"
class="pi pi-check text-xs text-text-primary"
/>
<span
v-else-if="benefit.type === 'metric' && benefit.value"
class="text-sm font-normal whitespace-nowrap text-text-primary"
>
{{ benefit.value }}
</span>
<span class="text-sm text-muted">
{{ benefit.label }}
</span>
</div>
</div>
</div>
</div>
</div>
<!-- View More Details - Outside main content -->
<div class="flex items-center gap-2 py-4">
<i class="pi pi-external-link text-muted"></i>
<a
href="https://www.comfy.org/cloud/pricing"
target="_blank"
rel="noopener noreferrer"
class="text-sm underline hover:opacity-80 text-muted"
>
{{ $t('subscription.viewMoreDetailsPlans') }}
</a>
</div>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import Menu from 'primevue/menu'
import Skeleton from 'primevue/skeleton'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import {
DEFAULT_TIER_KEY,
TIER_TO_KEY,
getTierCredits,
getTierFeatures,
getTierPrice
} from '@/platform/cloud/subscription/constants/tierPricing'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@/utils/tailwindUtil'
const authActions = useFirebaseAuthActions()
const workspaceStore = useTeamWorkspaceStore()
const { isWorkspaceSubscribed } = storeToRefs(workspaceStore)
const { subscribeWorkspace } = workspaceStore
const { permissions, workspaceRole } = useWorkspaceUI()
const { t, n } = useI18n()
// OWNER with unsubscribed workspace - can see subscribe button
const isOwnerUnsubscribed = computed(
() => workspaceRole.value === 'owner' && !isWorkspaceSubscribed.value
)
// MEMBER view - members can't manage subscription, show read-only zero state
const isMemberView = computed(() => !permissions.value.canManageSubscription)
// Show zero state for credits (no real billing data yet)
const showZeroState = computed(
() => isOwnerUnsubscribed.value || isMemberView.value
)
// Demo: Subscribe workspace to PRO monthly plan
function handleSubscribeWorkspace() {
subscribeWorkspace('PRO_MONTHLY')
}
const {
isActiveSubscription,
isCancelled,
formattedRenewalDate,
formattedEndDate,
subscriptionTier,
subscriptionTierName,
subscriptionStatus,
isYearlySubscription
} = useSubscription()
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
return TIER_TO_KEY[tier] ?? DEFAULT_TIER_KEY
})
const tierPrice = computed(() =>
getTierPrice(tierKey.value, isYearlySubscription.value)
)
const refillsDate = computed(() => {
if (!subscriptionStatus.value?.renewal_date) return ''
const date = new Date(subscriptionStatus.value.renewal_date)
const day = String(date.getDate()).padStart(2, '0')
const month = String(date.getMonth() + 1).padStart(2, '0')
const year = String(date.getFullYear()).slice(-2)
return `${month}/${day}/${year}`
})
const creditsRemainingLabel = computed(() =>
isYearlySubscription.value
? t('subscription.creditsRemainingThisYear', {
date: refillsDate.value
})
: t('subscription.creditsRemainingThisMonth', {
date: refillsDate.value
})
)
const planTotalCredits = computed(() => {
const credits = getTierCredits(tierKey.value)
const total = isYearlySubscription.value ? credits * 12 : credits
return n(total)
})
const includedCreditsDisplay = computed(
() => `${monthlyBonusCredits.value} / ${planTotalCredits.value}`
)
// Tier benefits for v-for loop
type BenefitType = 'metric' | 'feature'
interface Benefit {
key: string
type: BenefitType
label: string
value?: string
}
const tierBenefits = computed((): Benefit[] => {
const key = tierKey.value
const benefits: Benefit[] = [
{
key: 'maxDuration',
type: 'metric',
value: t(`subscription.maxDuration.${key}`),
label: t('subscription.maxDurationLabel')
},
{
key: 'gpu',
type: 'feature',
label: t('subscription.gpuLabel')
},
{
key: 'addCredits',
type: 'feature',
label: t('subscription.addCreditsLabel')
}
]
if (getTierFeatures(key).customLoRAs) {
benefits.push({
key: 'customLoRAs',
type: 'feature',
label: t('subscription.customLoRAsLabel')
})
}
return benefits
})
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
useSubscriptionCredits()
const { handleAddApiCredits, handleRefresh } = useSubscriptionActions()
// Focus-based polling: refresh balance when user returns from Stripe checkout
const PENDING_TOPUP_KEY = 'pending_topup_timestamp'
const TOPUP_EXPIRY_MS = 5 * 60 * 1000 // 5 minutes
function handleWindowFocus() {
const timestampStr = localStorage.getItem(PENDING_TOPUP_KEY)
if (!timestampStr) return
const timestamp = parseInt(timestampStr, 10)
// Clear expired tracking (older than 5 minutes)
if (Date.now() - timestamp > TOPUP_EXPIRY_MS) {
localStorage.removeItem(PENDING_TOPUP_KEY)
return
}
// Refresh and clear tracking to prevent repeated calls
void handleRefresh()
localStorage.removeItem(PENDING_TOPUP_KEY)
}
onMounted(() => {
window.addEventListener('focus', handleWindowFocus)
})
onBeforeUnmount(() => {
window.removeEventListener('focus', handleWindowFocus)
})
</script>
<style scoped>
:deep(.bg-comfy-menu-secondary) {
background-color: transparent;
}
</style>

View File

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

View File

@@ -1,6 +1,18 @@
<template>
<div class="settings-container">
<ScrollPanel class="settings-sidebar w-48 shrink-0 p-2 2xl:w-64">
<div
:class="
teamWorkspacesEnabled
? 'flex h-[80vh] w-full overflow-hidden'
: 'settings-container'
"
>
<ScrollPanel
:class="
teamWorkspacesEnabled
? 'w-48 shrink-0 p-2 2xl:w-64'
: 'settings-sidebar w-48 shrink-0 p-2 2xl:w-64'
"
>
<SearchBox
v-model:model-value="searchQuery"
class="settings-search-box mb-2 w-full"
@@ -20,16 +32,40 @@
(option: SettingTreeNode) =>
!queryIsEmpty && !searchResultsCategories.has(option.label ?? '')
"
class="w-full border-none"
:class="
teamWorkspacesEnabled
? 'w-full border-none bg-transparent'
: 'w-full border-none'
"
>
<template #optiongroup>
<!-- Workspace mode: custom group headers -->
<template v-if="teamWorkspacesEnabled" #optiongroup="{ option }">
<h3 class="text-xs font-semibold uppercase text-muted m-0 pt-6 pb-2">
{{ option.label }}
</h3>
</template>
<!-- Legacy mode: divider between groups -->
<template v-else #optiongroup>
<Divider class="my-0" />
</template>
<!-- Workspace mode: custom workspace item -->
<template v-if="teamWorkspacesEnabled" #option="{ option }">
<WorkspaceSidebarItem v-if="option.key === 'workspace'" />
<span v-else>{{ option.translatedLabel }}</span>
</template>
</Listbox>
</ScrollPanel>
<Divider layout="vertical" class="mx-1 hidden md:flex 2xl:mx-4" />
<Divider layout="horizontal" class="flex md:hidden" />
<Tabs :value="tabValue" :lazy="true" class="settings-content h-full w-full">
<Tabs
:value="tabValue"
:lazy="true"
:class="
teamWorkspacesEnabled
? 'h-full flex-1 overflow-x-auto'
: 'settings-content h-full w-full'
"
>
<TabPanels class="settings-tab-panels h-full w-full pr-0">
<PanelTemplate value="Search Results">
<SettingsPanel :setting-groups="searchResults" />
@@ -48,7 +84,7 @@
</PanelTemplate>
<Suspense v-for="panel in panels" :key="panel.node.key">
<component :is="panel.component" />
<component :is="panel.component" v-bind="panel.props" />
<template #fallback>
<div>{{ $t('g.loadingPanel', { panel: panel.node.label }) }}</div>
</template>
@@ -69,7 +105,10 @@ import { computed, watch } from 'vue'
import SearchBox from '@/components/common/SearchBox.vue'
import CurrentUserMessage from '@/components/dialog/content/setting/CurrentUserMessage.vue'
import PanelTemplate from '@/components/dialog/content/setting/PanelTemplate.vue'
import WorkspaceSidebarItem from '@/components/dialog/content/setting/WorkspaceSidebarItem.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import ColorPaletteMessage from '@/platform/settings/components/ColorPaletteMessage.vue'
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
@@ -86,8 +125,13 @@ const { defaultPanel } = defineProps<{
| 'server-config'
| 'user'
| 'credits'
| 'subscription'
| 'workspace'
}>()
const { flags } = useFeatureFlags()
const teamWorkspacesEnabled = isCloud && flags.teamWorkspacesEnabled
const {
activeCategory,
defaultCategory,
@@ -162,6 +206,7 @@ watch(activeCategory, (_, oldValue) => {
</style>
<style scoped>
/* Legacy mode styles (when teamWorkspacesEnabled is false) */
.settings-container {
display: flex;
height: 70vh;
@@ -190,7 +235,7 @@ watch(activeCategory, (_, oldValue) => {
}
}
/* Hide the first group separator */
/* Hide the first group separator in legacy mode */
.settings-sidebar :deep(.p-listbox-option-group:nth-child(1)) {
display: none;
}

View File

@@ -3,19 +3,21 @@ import type { Component } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import type { SettingTreeNode } from '@/platform/settings/settingStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { SettingParams } from '@/platform/settings/types'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isElectron } from '@/utils/envUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { buildTree } from '@/utils/treeUtil'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
interface SettingPanelItem {
node: SettingTreeNode
component: Component
props?: Record<string, unknown>
}
export function useSettingUI(
@@ -27,15 +29,19 @@ export function useSettingUI(
| 'user'
| 'credits'
| 'subscription'
| 'workspace'
) {
const { t } = useI18n()
const { isLoggedIn } = useCurrentUser()
const settingStore = useSettingStore()
const activeCategory = ref<SettingTreeNode | null>(null)
const { flags } = useFeatureFlags()
const { shouldRenderVueNodes } = useVueFeatureFlags()
const { isActiveSubscription } = useSubscription()
const teamWorkspacesEnabled = isCloud && flags.teamWorkspacesEnabled
const settingRoot = computed<SettingTreeNode>(() => {
const root = buildTree(
Object.values(settingStore.settingsById).filter(
@@ -64,6 +70,33 @@ export function useSettingUI(
() => settingRoot.value.children ?? []
)
// Core setting categories (built-in to ComfyUI) in display order
// 'Other' includes floating settings that don't have a specific category
const CORE_CATEGORIES_ORDER = [
'Comfy',
'LiteGraph',
'Appearance',
'3D',
'Mask Editor',
'Other'
]
const CORE_CATEGORIES = new Set(CORE_CATEGORIES_ORDER)
const coreSettingCategories = computed<SettingTreeNode[]>(() => {
const categories = settingCategories.value.filter((node) =>
CORE_CATEGORIES.has(node.label)
)
return categories.sort(
(a, b) =>
CORE_CATEGORIES_ORDER.indexOf(a.label) -
CORE_CATEGORIES_ORDER.indexOf(b.label)
)
})
const customNodeSettingCategories = computed<SettingTreeNode[]>(() =>
settingCategories.value.filter((node) => !CORE_CATEGORIES.has(node.label))
)
// Define panel items
const aboutPanel: SettingPanelItem = {
node: {
@@ -118,6 +151,25 @@ export function useSettingUI(
)
}
// Workspace panel: only available on cloud with team workspaces enabled
const workspacePanel: SettingPanelItem | null = !teamWorkspacesEnabled
? null
: {
node: {
key: 'workspace',
label: 'Workspace',
children: []
},
component: defineAsyncComponent(
() => import('@/components/dialog/content/setting/WorkspacePanel.vue')
)
}
const shouldShowWorkspacePanel = computed(() => {
if (!workspacePanel) return false
return isLoggedIn.value
})
const keybindingPanel: SettingPanelItem = {
node: {
key: 'keybinding',
@@ -156,13 +208,16 @@ export function useSettingUI(
aboutPanel,
creditsPanel,
userPanel,
...(shouldShowWorkspacePanel.value && workspacePanel
? [workspacePanel]
: []),
keybindingPanel,
extensionPanel,
...(isElectron() ? [serverConfigPanel] : []),
...(shouldShowPlanCreditsPanel.value && subscriptionPanel
? [subscriptionPanel]
: [])
].filter((panel) => panel.component)
].filter((panel) => panel !== null && panel.component)
)
/**
@@ -186,7 +241,49 @@ export function useSettingUI(
)
})
const groupedMenuTreeNodes = computed<SettingTreeNode[]>(() => [
// Sidebar structure when team workspaces is enabled
const workspaceMenuTreeNodes = computed<SettingTreeNode[]>(() => [
// Workspace settings
{
key: 'workspace',
label: 'Workspace',
children: [
...(shouldShowWorkspacePanel.value && workspacePanel
? [workspacePanel.node]
: []),
...(isLoggedIn.value &&
!(isCloud && window.__CONFIG__?.subscription_required)
? [creditsPanel.node]
: [])
].map(translateCategory)
},
// General settings - Profile + all core settings + special panels
{
key: 'general',
label: 'General',
children: [
translateCategory(userPanel.node),
...coreSettingCategories.value.map(translateCategory),
translateCategory(keybindingPanel.node),
translateCategory(extensionPanel.node),
translateCategory(aboutPanel.node),
...(isElectron() ? [translateCategory(serverConfigPanel.node)] : [])
]
},
// Custom node settings (only shown if custom nodes have registered settings)
...(customNodeSettingCategories.value.length > 0
? [
{
key: 'other',
label: 'Other',
children: customNodeSettingCategories.value.map(translateCategory)
}
]
: [])
])
// Sidebar structure when team workspaces is disabled (legacy)
const legacyMenuTreeNodes = computed<SettingTreeNode[]>(() => [
// Account settings - show different panels based on distribution and auth state
{
key: 'account',
@@ -223,6 +320,12 @@ export function useSettingUI(
}
])
const groupedMenuTreeNodes = computed<SettingTreeNode[]>(() =>
teamWorkspacesEnabled
? workspaceMenuTreeNodes.value
: legacyMenuTreeNodes.value
)
onMounted(() => {
activeCategory.value = defaultCategory.value
})

View File

@@ -0,0 +1,753 @@
# Workspaces System: Complete Implementation Spec
## Overview
ComfyUI is moving from a 1:1 User↔Plan billing model to a Workspace-centric model. Users will have a personal workspace (auto-created) and can create/join shared workspaces with other users.
This document is the single source of truth for implementing the frontend workspace system. It covers the authentication flow, storage strategy, store architecture, and step-by-step implementation instructions.
---
## Part 1: Core Concepts
### Workspace Model
- **Personal Workspace**: Auto-created for every user on the backend (already backfilled). Default fallback. Cannot be deleted.
- **Shared Workspace**: User-created. Can have multiple members with roles.
- **Roles**: `owner` (full access) or `member` (limited access). Permissions-based flows planned for future.
### Storage Strategy
| Storage | Scope | Contents | Lifetime |
| ------------------ | ------------ | -------------------- | --------------- |
| **localStorage** | Browser-wide | Firebase auth token | Until logout |
| **sessionStorage** | Per-tab | Current workspace ID | Until tab close |
This enables:
- Single auth source (Firebase token in localStorage)
- Independent workspace contexts per tab
---
## Part 2: Authentication & Session Flow
### On App Boot
```
1. Check localStorage for Firebase auth token
└─ No token? → Redirect to login
2. Fetch GET /workspaces with Bearer token
└─ Returns array of workspaces (always includes personal)
3. Check sessionStorage for workspace ID
└─ If exists AND user has access → use it
└─ Otherwise → fallback to personal workspace
4. Set active workspace ID in sessionStorage
5. App ready with workspace context
```
### On Workspace Switch
```
1. Verify target workspace exists (GET /workspaces/:id)
└─ 404/403? → Show error, refresh workspace list
2. Set new workspace ID in sessionStorage
3. Full page reload
└─ Clears all in-memory state
└─ App boots fresh with new workspace context
```
**Why reload?** Workspace-scoped data (assets, settings, etc.) lives in various stores. Reload guarantees clean slate. Simple > clever.
### Edge Cases
| Scenario | Behavior |
| ---------------------------------- | ----------------------------------------------- |
| Tab refresh | sessionStorage persists → same workspace |
| Tab/browser close | sessionStorage cleared → falls back to personal |
| Logout in any tab | localStorage cleared → all tabs lose auth |
| Removed from workspace mid-session | Next API call fails → redirect to personal |
---
## Part 3: Architecture
### Layer Diagram
```
┌─────────────────────────────────────────────────────────────────┐
│ Vue Components │
│(WorkspaceSwitcherPopover, CRUD Dialogs, WorkspacePanelContent, etc.) │
└─────────────────────────────┬───────────────────────────────────┘
┌───────────────┴───────────────┐
▼ ▼
┌──────────────────────────┐ ┌──────────────────────────────┐
│ useWorkspaceUI() │ │ useWorkspaceStore() │
│ - uiConfig computed │ │ - Pinia store │
│ - Role-based UI flags │ │ - State + Actions │
└──────────────────────────┘ └──────────────┬───────────────┘
┌────────────────┴────────────────┐
▼ ▼
┌──────────────────────────┐ ┌─────────────────────────────┐
│ workspaceApi │ │ sessionManager │
│ - Pure HTTP calls │ │ - sessionStorage r/w │
│ - Returns promises │ │ - Reload trigger │
└──────────────────────────┘ └─────────────────────────────┘
```
### File Structure
```
workspaces/
├── services/
│ ├── workspaceApi.ts # KEEP EXISTING (but update if needed) - already good
│ └── session-manager.ts # CREATE NEW
├── stores/
│ └── workspace-store.ts # CREATE NEW (Pinia)
└── composables/
└── use-workspace-ui.ts # CREATE NEW
```
---
## Part 4: Implementation
### Step 1: Create `sessionManager.ts`
Location: `src/services/sessionManager.ts`
```typescript
// src/services/sessionManager.ts
const WORKSPACE_SESSION_KEY = 'currentWorkspaceId'
export const sessionManager = {
/**
* Get Firebase auth token.
* IMPORTANT: Look at existing workspaceAuthStore.ts to see how
* the Firebase token is currently retrieved. Match that approach here.
* It's likely either:
* - Direct localStorage read
* - Firebase Auth SDK call like firebase.auth().currentUser?.getIdToken()
*/
getFirebaseToken(): string | null {
// TODO: Extract from existing code
// Example: return localStorage.getItem('firebaseToken');
throw new Error('Implement based on existing Firebase token retrieval')
},
getCurrentWorkspaceId(): string | null {
return sessionStorage.getItem(WORKSPACE_SESSION_KEY)
},
setCurrentWorkspaceId(workspaceId: string): void {
sessionStorage.setItem(WORKSPACE_SESSION_KEY, workspaceId)
},
clearCurrentWorkspaceId(): void {
sessionStorage.removeItem(WORKSPACE_SESSION_KEY)
},
/**
* THE way to switch workspaces. Sets ID and reloads.
* Code after calling this won't execute (page is gone).
*/
switchWorkspaceAndReload(workspaceId: string): void {
this.setCurrentWorkspaceId(workspaceId)
window.location.reload()
},
/**
* For bailing to personal workspace (e.g., after deletion).
*/
clearAndReload(): void {
this.clearCurrentWorkspaceId()
window.location.reload()
}
}
```
---
### Step 2: Create `workspaceStore.ts`
Location: `src/stores/workspaceStore.ts`
```typescript
// src/stores/workspaceStore.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { workspaceApi, ApiError } from '@/services/workspace-api'
import { sessionManager } from '@/services/session-manager'
// Import Workspace type from existing workspaceApi.ts
import type { Workspace, InviteLink } from '@/services/workspaceApi'
type InitState = 'uninitialized' | 'loading' | 'ready' | 'error'
export const useWorkspaceStore = defineStore('workspace', () => {
// ════════════════════════════════════════════════════════════
// STATE
// ════════════════════════════════════════════════════════════
const initState = ref<InitState>('uninitialized')
const workspaces = ref<Workspace[]>([])
const activeWorkspaceId = ref<string | null>(null)
const error = ref<Error | null>(null)
// Loading states for UI
const isCreating = ref(false)
const isDeleting = ref(false)
const isSwitching = ref(false)
// ════════════════════════════════════════════════════════════
// COMPUTED
// ════════════════════════════════════════════════════════════
const activeWorkspace = computed(
() => workspaces.value.find((w) => w.id === activeWorkspaceId.value) ?? null
)
const personalWorkspace = computed(
() => workspaces.value.find((w) => w.isPersonal) ?? null
)
const isInPersonalWorkspace = computed(
() => activeWorkspace.value?.isPersonal ?? false
)
const sharedWorkspaces = computed(() =>
workspaces.value.filter((w) => !w.isPersonal)
)
// ════════════════════════════════════════════════════════════
// INITIALIZATION
// ════════════════════════════════════════════════════════════
/**
* Call once on app boot.
* Fetches workspaces, resolves active workspace.
*/
async function initialize(): Promise<void> {
if (initState.value !== 'uninitialized') return
initState.value = 'loading'
error.value = null
try {
// 1. Fetch all workspaces
workspaces.value = await workspaceApi.list()
// 2. Determine active workspace
const sessionId = sessionManager.getCurrentWorkspaceId()
let target: Workspace | undefined
if (sessionId) {
target = workspaces.value.find((w) => w.id === sessionId)
}
if (!target) {
target = workspaces.value.find((w) => w.isPersonal)
}
if (!target) {
throw new Error('No workspace available')
}
// 3. Set active
activeWorkspaceId.value = target.id
sessionManager.setCurrentWorkspaceId(target.id)
initState.value = 'ready'
} catch (e) {
error.value = e instanceof Error ? e : new Error('Unknown error')
initState.value = 'error'
throw e
}
}
// ════════════════════════════════════════════════════════════
// ACTIONS
// ════════════════════════════════════════════════════════════
/**
* Switch to a different workspace.
* Verifies it exists, then reloads.
*/
async function switchWorkspace(workspaceId: string): Promise<void> {
if (workspaceId === activeWorkspaceId.value) return
isSwitching.value = true
try {
// Verify workspace exists and user has access
await workspaceApi.get(workspaceId)
// Success → switch and reload
sessionManager.switchWorkspaceAndReload(workspaceId)
// Code after this won't run
} catch (e) {
isSwitching.value = false
if (e instanceof ApiError && (e.status === 404 || e.status === 403)) {
// Workspace gone or access revoked
workspaces.value = await workspaceApi.list()
throw new Error('Workspace no longer available')
}
throw e
}
}
/**
* Create a new workspace and switch to it.
*/
async function createWorkspace(name: string): Promise<void> {
isCreating.value = true
try {
const newWorkspace = await workspaceApi.create(name)
sessionManager.switchWorkspaceAndReload(newWorkspace.id)
// Code after this won't run
} catch (e) {
isCreating.value = false
throw e
}
}
/**
* Delete a workspace.
* If deleting active → switches to personal.
*/
async function deleteWorkspace(workspaceId: string): Promise<void> {
const workspace = workspaces.value.find((w) => w.id === workspaceId)
if (!workspace) throw new Error('Workspace not found')
if (workspace.isPersonal)
throw new Error('Cannot delete personal workspace')
isDeleting.value = true
try {
await workspaceApi.delete(workspaceId)
if (workspaceId === activeWorkspaceId.value) {
// Deleted active → go to personal
const personal = personalWorkspace.value
if (personal) {
sessionManager.switchWorkspaceAndReload(personal.id)
} else {
sessionManager.clearAndReload()
}
// Code after this won't run
} else {
// Deleted non-active → just update local list
workspaces.value = workspaces.value.filter((w) => w.id !== workspaceId)
isDeleting.value = false
}
} catch (e) {
isDeleting.value = false
throw e
}
}
/**
* Rename a workspace. No reload needed.
*/
async function renameWorkspace(
workspaceId: string,
newName: string
): Promise<void> {
const updated = await workspaceApi.update(workspaceId, { name: newName })
const index = workspaces.value.findIndex((w) => w.id === workspaceId)
if (index !== -1) {
workspaces.value[index] = updated
}
}
/**
* Create invite link for current workspace.
*/
async function createInvite(email: string): Promise<InviteLink> {
if (!activeWorkspaceId.value) {
throw new Error('No active workspace')
}
return workspaceApi.createInvite(activeWorkspaceId.value, email)
}
/**
* Accept invite. Does NOT auto-switch.
* Returns workspace so UI can offer "View Workspace" button.
*/
async function acceptInvite(token: string): Promise<Workspace> {
const workspace = await workspaceApi.acceptInvite(token)
if (!workspaces.value.find((w) => w.id === workspace.id)) {
workspaces.value.push(workspace)
}
return workspace
}
/**
* Leave a workspace (remove self).
* If leaving active → switches to personal.
*/
async function leaveWorkspace(
workspaceId: string,
userId: string
): Promise<void> {
await workspaceApi.removeMember(workspaceId, userId)
if (workspaceId === activeWorkspaceId.value) {
const personal = personalWorkspace.value
if (personal) {
sessionManager.switchWorkspaceAndReload(personal.id)
}
} else {
workspaces.value = workspaces.value.filter((w) => w.id !== workspaceId)
}
}
// ════════════════════════════════════════════════════════════
// RETURN
// ════════════════════════════════════════════════════════════
return {
// State
initState,
workspaces,
activeWorkspaceId,
error,
isCreating,
isDeleting,
isSwitching,
// Computed
activeWorkspace,
personalWorkspace,
isInPersonalWorkspace,
sharedWorkspaces,
// Actions
initialize,
switchWorkspace,
createWorkspace,
deleteWorkspace,
renameWorkspace,
createInvite,
acceptInvite,
leaveWorkspace
}
})
```
---
### Step 3: Create `useWorkspaceUI.ts`
Location: `src/composables/useWorkspaceUI.ts`
This composable extracts UI configuration logic from the existing `useWorkspace.ts`. It computes role-based UI flags from the store state.
```typescript
// src/composables/use-workspace-ui.ts
import { computed } from 'vue'
import { useWorkspaceStore } from '@/stores/workspace-store'
/**
* UI configuration derived from workspace state.
* Controls what UI elements are visible/enabled based on role and workspace type.
*/
export interface WorkspaceUIConfig {
// Workspace management
canInviteMembers: boolean
canDeleteWorkspace: boolean
canRenameWorkspace: boolean
canManageMembers: boolean
canLeaveWorkspace: boolean
// Add any other UI flags from existing use-workspace.ts uiConfig here
// Example:
// canViewBilling: boolean;
// canChangeSettings: boolean;
// showMemberList: boolean;
}
function getDefaultUIConfig(): WorkspaceUIConfig {
return {
canInviteMembers: false,
canDeleteWorkspace: false,
canRenameWorkspace: false,
canManageMembers: false,
canLeaveWorkspace: false
}
}
export function useWorkspaceUI() {
const store = useWorkspaceStore()
const uiConfig = computed<WorkspaceUIConfig>(() => {
const workspace = store.activeWorkspace
if (!workspace) {
return getDefaultUIConfig()
}
const isOwner = workspace.role === 'owner'
const isPersonal = workspace.isPersonal
return {
canInviteMembers: isOwner && !isPersonal,
canDeleteWorkspace: isOwner && !isPersonal,
canRenameWorkspace: isOwner,
canManageMembers: isOwner && !isPersonal,
canLeaveWorkspace: !isPersonal && !isOwner
// IMPORTANT: Add all other UI flags from existing use-workspace.ts
// Look for the existing uiConfig object and migrate ALL properties here
}
})
// Convenience re-exports so components don't need both imports
return {
uiConfig,
activeWorkspace: computed(() => store.activeWorkspace),
workspaces: computed(() => store.workspaces),
personalWorkspace: computed(() => store.personalWorkspace),
isLoading: computed(() => store.initState === 'loading'),
isReady: computed(() => store.initState === 'ready'),
isError: computed(() => store.initState === 'error')
}
}
```
**IMPORTANT:** The existing `useWorkspace.ts` has a `uiConfig` object with specific properties. Extract ALL of them into this composable. The properties I listed are examples—the real implementation should include every UI flag the existing code uses.
---
### Step 4: Update Component Imports
Find all components importing from old files and update:
**Before:**
```typescript
import { useWorkspace } from '@/composables/use-workspace'
import { useWorkspaceAuthStore } from '@/stores/workspace-auth-store'
```
**After:**
```typescript
import { useWorkspaceStore } from '@/stores/workspace-store'
import { useWorkspaceUI } from '@/composables/use-workspace-ui'
```
**Usage patterns:**
For actions (create, switch, delete, etc.):
```typescript
const store = useWorkspaceStore()
async function handleCreate() {
try {
await store.createWorkspace(name)
// Won't reach here - page reloads
} catch (e) {
showError(e.message)
}
}
```
For UI config and display:
```typescript
const { uiConfig, activeWorkspace, isLoading } = useWorkspaceUI()
// In template:
// <Button v-if="uiConfig.canInviteMembers">Invite</Button>
// <span>{{ activeWorkspace?.name }}</span>
```
---
### Step 5: Wire Up App Initialization
Find where the app initializes (likely `App.vue`, `main.ts`, or a router guard) and add:
```typescript
import { useWorkspaceStore } from '@/stores/workspace-store'
// In App.vue setup:
const workspaceStore = useWorkspaceStore()
onMounted(async () => {
try {
await workspaceStore.initialize()
} catch (e) {
// Handle failure - show error screen or redirect to login
console.error('Workspace initialization failed:', e)
}
})
// Or as a router guard:
router.beforeEach(async (to, from, next) => {
const store = useWorkspaceStore()
if (store.initState === 'uninitialized') {
try {
await store.initialize()
} catch (e) {
return next('/login')
}
}
next()
})
```
---
### Step 6: Handle Invite URL Flow
If there's a route handling `?invite=TOKEN`:
```typescript
// In the component or route handler
import { useRoute, useRouter } from 'vue-router'
import { useWorkspaceStore } from '@/stores/workspace-store'
const route = useRoute()
const router = useRouter()
const store = useWorkspaceStore()
const inviteToken = route.query.invite as string
if (inviteToken) {
try {
const workspace = await store.acceptInvite(inviteToken)
// Show success dialog
showDialog({
title: 'Invite Accepted',
message: `You've joined ${workspace.name}`,
actions: [
{
label: 'View Workspace',
onClick: () => store.switchWorkspace(workspace.id)
},
{ label: 'Stay Here', onClick: () => {} }
]
})
} catch (e) {
showError('Invite is invalid or expired')
}
// Clean URL
router.replace({ query: {} })
}
```
---
### Step 7: Delete Old Files
Once everything works:
1. ❌ Delete `workspaceAuthStore.ts`
2. ❌ Delete `useWorkspace.ts`
3. ✅ Keep `workspaceApi.ts`
---
## Part 5: Testing Checklist
### Core Flows
- [ ] App boot → workspaces load → personal workspace active
- [ ] Create workspace → page reloads → new workspace active
- [ ] Switch workspace → page reloads → correct workspace active
- [ ] Delete active workspace → page reloads → personal workspace active
- [ ] Delete non-active workspace → list updates → no reload
- [ ] Rename workspace → name updates → no reload
### Session Behavior
- [ ] Refresh tab → same workspace (sessionStorage persists)
- [ ] Close tab, reopen → personal workspace (sessionStorage cleared)
- [ ] Close browser, reopen → personal workspace (sessionStorage cleared)
- [ ] Multiple tabs → each can have different workspace
### Invite Flow
- [ ] Create invite → returns URL with token
- [ ] Accept invite → workspace added to list
- [ ] Click "View Workspace" after accept → switches and reloads
### UI Config
- [ ] Owner of shared workspace → all actions enabled
- [ ] Member of shared workspace → limited actions
- [ ] Personal workspace → cannot delete, cannot leave
### Error Handling
- [ ] Workspace deleted while viewing → graceful redirect to personal
- [ ] Network error during create → error shown, no reload
- [ ] Invalid invite token → error shown
---
## Part 6: Reference - The Reload Pattern
| Operation | After Success |
| ----------------------- | ----------------------------------------------- |
| Create workspace | Set session ID → **Reload** |
| Switch workspace | Set session ID → **Reload** |
| Delete active workspace | Set personal ID → **Reload** |
| Delete other workspace | Update local list (no reload) |
| Rename workspace | Update local state (no reload) |
| Accept invite | Add to list (no reload, user chooses to switch) |
| Leave active workspace | Set personal ID → **Reload** |
| Leave other workspace | Remove from list (no reload) |
**Rule:** If active workspace changes → reload. Otherwise → just update local state.
---
## Part 7: Things to Watch For
1. **Firebase Token Retrieval**
- Look at existing `workspaceAuthStore.ts` to see how it gets the Firebase token
- It might be `localStorage`, Firebase SDK, or a custom auth service
- Match that in `sessionManager.getFirebaseToken()`
2. **Token Refresh/Expiry**
- Old code may have timer-based token refresh
- With reload pattern, this may not be needed—each reload gets fresh token
- Test to confirm
3. **Existing uiConfig Properties**
- The existing `useWorkspace.ts` has specific `uiConfig` properties
- Extract ALL of them—don't miss any or components will break
4. **API Endpoint Paths**
- The `workspaceApi.ts` file already has the correct endpoints
- Don't change them unless the backend changed
5. **Type Definitions**
- `Workspace`, `InviteLink`, etc. should already exist in `workspaceApi.ts`
- Import from there, don't redefine

View File

@@ -0,0 +1,334 @@
import type { AxiosResponse } from 'axios'
import axios from 'axios'
import { t } from '@/i18n'
import { api } from '@/scripts/api'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { AuthHeader } from '@/types/authTypes'
// Types aligned with backend API
export type WorkspaceType = 'personal' | 'team'
export type WorkspaceRole = 'owner' | 'member'
interface Workspace {
id: string
name: string
type: WorkspaceType
}
export interface WorkspaceWithRole extends Workspace {
role: WorkspaceRole
}
// Member type from API
export interface Member {
id: string
name: string
email: string
joined_at: string
}
interface PaginationInfo {
offset: number
limit: number
total: number
}
interface ListMembersResponse {
members: Member[]
pagination: PaginationInfo
}
export interface ListMembersParams {
offset?: number
limit?: number
}
// Pending invite type from API
export interface PendingInvite {
id: string
email: string
token: string
invited_at: string
expires_at: string
}
interface ListInvitesResponse {
invites: PendingInvite[]
}
interface CreateInviteRequest {
email: string
}
interface AcceptInviteResponse {
workspace_id: string
workspace_name: string
}
// Billing types (POST /api/billing/portal)
interface BillingPortalRequest {
return_url: string
}
interface BillingPortalResponse {
billing_portal_url: string
}
interface CreateWorkspacePayload {
name: string
}
interface UpdateWorkspacePayload {
name: string
}
// API responses
interface ListWorkspacesResponse {
workspaces: WorkspaceWithRole[]
}
// Token exchange types (POST /api/auth/token)
interface ExchangeTokenRequest {
workspace_id: string
}
export interface ExchangeTokenResponse {
token: string
expires_at: string
workspace: Workspace
role: WorkspaceRole
permissions: string[]
}
export class WorkspaceApiError extends Error {
constructor(
message: string,
public readonly status?: number,
public readonly code?: string
) {
super(message)
this.name = 'WorkspaceApiError'
}
}
const workspaceApiClient = axios.create({
headers: {
'Content-Type': 'application/json'
}
})
async function withAuth<T>(
request: (headers: AuthHeader) => Promise<AxiosResponse<T>>
): Promise<T> {
const authHeader = await useFirebaseAuthStore().getAuthHeader()
if (!authHeader) {
throw new WorkspaceApiError(
t('toastMessages.userNotAuthenticated'),
401,
'NOT_AUTHENTICATED'
)
}
try {
const response = await request(authHeader)
return response.data
} catch (err) {
if (axios.isAxiosError(err)) {
const status = err.response?.status
const message = err.response?.data?.message ?? err.message
throw new WorkspaceApiError(message, status)
}
throw err
}
}
/**
* Wrapper that uses Firebase ID token directly (not workspace token).
* Used for token exchange where we need the Firebase token to get a workspace token.
*/
async function withFirebaseAuth<T>(
request: (headers: AuthHeader) => Promise<AxiosResponse<T>>
): Promise<T> {
const firebaseToken = await useFirebaseAuthStore().getIdToken()
if (!firebaseToken) {
throw new WorkspaceApiError(
t('toastMessages.userNotAuthenticated'),
401,
'NOT_AUTHENTICATED'
)
}
const headers: AuthHeader = { Authorization: `Bearer ${firebaseToken}` }
try {
const response = await request(headers)
return response.data
} catch (err) {
if (axios.isAxiosError(err)) {
const status = err.response?.status
const message = err.response?.data?.message ?? err.message
const code =
status === 401
? 'INVALID_FIREBASE_TOKEN'
: status === 403
? 'ACCESS_DENIED'
: status === 404
? 'WORKSPACE_NOT_FOUND'
: 'TOKEN_EXCHANGE_FAILED'
throw new WorkspaceApiError(message, status, code)
}
throw err
}
}
export const workspaceApi = {
/**
* List all workspaces the user has access to
* GET /api/workspaces
*/
list: (): Promise<ListWorkspacesResponse> =>
withAuth((headers) =>
workspaceApiClient.get(api.apiURL('/workspaces'), { headers })
),
/**
* Create a new workspace
* POST /api/workspaces
*/
create: (payload: CreateWorkspacePayload): Promise<WorkspaceWithRole> =>
withAuth((headers) =>
workspaceApiClient.post(api.apiURL('/workspaces'), payload, { headers })
),
/**
* Update workspace name
* PATCH /api/workspaces/:id
*/
update: (
workspaceId: string,
payload: UpdateWorkspacePayload
): Promise<WorkspaceWithRole> =>
withAuth((headers) =>
workspaceApiClient.patch(
api.apiURL(`/workspaces/${workspaceId}`),
payload,
{ headers }
)
),
/**
* Delete a workspace (owner only)
* DELETE /api/workspaces/:id
*/
delete: (workspaceId: string): Promise<void> =>
withAuth((headers) =>
workspaceApiClient.delete(api.apiURL(`/workspaces/${workspaceId}`), {
headers
})
),
/**
* Leave the current workspace.
* POST /api/workspace/leave
*/
leave: (): Promise<void> =>
withAuth((headers) =>
workspaceApiClient.post(api.apiURL('/workspace/leave'), null, { headers })
),
/**
* List workspace members (paginated).
* GET /api/workspace/members
*/
listMembers: (params?: ListMembersParams): Promise<ListMembersResponse> =>
withAuth((headers) =>
workspaceApiClient.get(api.apiURL('/workspace/members'), {
headers,
params
})
),
/**
* Remove a member from the workspace.
* DELETE /api/workspace/members/:userId
*/
removeMember: (userId: string): Promise<void> =>
withAuth((headers) =>
workspaceApiClient.delete(api.apiURL(`/workspace/members/${userId}`), {
headers
})
),
/**
* List pending invites for the workspace.
* GET /api/workspace/invites
*/
listInvites: (): Promise<ListInvitesResponse> =>
withAuth((headers) =>
workspaceApiClient.get(api.apiURL('/workspace/invites'), { headers })
),
/**
* Create an invite for the workspace.
* POST /api/workspace/invites
*/
createInvite: (payload: CreateInviteRequest): Promise<PendingInvite> =>
withAuth((headers) =>
workspaceApiClient.post(api.apiURL('/workspace/invites'), payload, {
headers
})
),
/**
* Revoke a pending invite.
* DELETE /api/workspace/invites/:inviteId
*/
revokeInvite: (inviteId: string): Promise<void> =>
withAuth((headers) =>
workspaceApiClient.delete(api.apiURL(`/workspace/invites/${inviteId}`), {
headers
})
),
/**
* Accept a workspace invite.
* POST /api/invites/:token/accept
*/
acceptInvite: (token: string): Promise<AcceptInviteResponse> =>
withAuth((headers) =>
workspaceApiClient.post(api.apiURL(`/invites/${token}/accept`), null, {
headers
})
),
/**
* Exchange Firebase JWT for workspace-scoped Cloud JWT.
* POST /api/auth/token
*
* Uses Firebase ID token directly (not getAuthHeader) since we're
* exchanging it for a workspace-scoped token.
*/
exchangeToken: (workspaceId: string): Promise<ExchangeTokenResponse> =>
withFirebaseAuth((headers) =>
workspaceApiClient.post(
api.apiURL('/auth/token'),
{ workspace_id: workspaceId } satisfies ExchangeTokenRequest,
{ headers }
)
),
/**
* Access the billing portal for the current workspace.
* POST /api/billing/portal
*
* Uses workspace-scoped token to get billing portal URL.
*/
accessBillingPortal: (returnUrl?: string): Promise<BillingPortalResponse> =>
withAuth((headers) =>
workspaceApiClient.post(
api.apiURL('/billing/portal'),
{
return_url: returnUrl ?? window.location.href
} satisfies BillingPortalRequest,
{ headers }
)
)
}

View File

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

View File

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

View File

@@ -0,0 +1,177 @@
import { computed, ref } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import type { WorkspaceRole, WorkspaceType } from '../api/workspaceApi'
import { useTeamWorkspaceStore } from '../stores/teamWorkspaceStore'
/** Permission flags for workspace actions */
interface WorkspacePermissions {
canViewOtherMembers: boolean
canViewPendingInvites: boolean
canInviteMembers: boolean
canManageInvites: boolean
canRemoveMembers: boolean
canLeaveWorkspace: boolean
canAccessWorkspaceMenu: boolean
canManageSubscription: boolean
}
/** UI configuration for workspace role */
interface WorkspaceUIConfig {
showMembersList: boolean
showPendingTab: boolean
showSearch: boolean
showDateColumn: boolean
showRoleBadge: boolean
membersGridCols: string
pendingGridCols: string
headerGridCols: string
showEditWorkspaceMenuItem: boolean
workspaceMenuAction: 'leave' | 'delete' | null
workspaceMenuDisabledTooltip: string | null
}
function getPermissions(
type: WorkspaceType,
role: WorkspaceRole
): WorkspacePermissions {
if (type === 'personal') {
return {
canViewOtherMembers: false,
canViewPendingInvites: false,
canInviteMembers: false,
canManageInvites: false,
canRemoveMembers: false,
canLeaveWorkspace: false,
canAccessWorkspaceMenu: true,
canManageSubscription: true
}
}
if (role === 'owner') {
return {
canViewOtherMembers: true,
canViewPendingInvites: true,
canInviteMembers: true,
canManageInvites: true,
canRemoveMembers: true,
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: true
}
}
// member role
return {
canViewOtherMembers: true,
canViewPendingInvites: false,
canInviteMembers: false,
canManageInvites: false,
canRemoveMembers: false,
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: false
}
}
function getUIConfig(
type: WorkspaceType,
role: WorkspaceRole
): WorkspaceUIConfig {
if (type === 'personal') {
return {
showMembersList: false,
showPendingTab: false,
showSearch: false,
showDateColumn: false,
showRoleBadge: false,
membersGridCols: 'grid-cols-1',
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
headerGridCols: 'grid-cols-1',
showEditWorkspaceMenuItem: true,
workspaceMenuAction: null,
workspaceMenuDisabledTooltip: null
}
}
if (role === 'owner') {
return {
showMembersList: true,
showPendingTab: true,
showSearch: true,
showDateColumn: true,
showRoleBadge: true,
membersGridCols: 'grid-cols-[50%_40%_10%]',
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
headerGridCols: 'grid-cols-[50%_40%_10%]',
showEditWorkspaceMenuItem: true,
workspaceMenuAction: 'delete',
workspaceMenuDisabledTooltip:
'workspacePanel.menu.deleteWorkspaceDisabledTooltip'
}
}
// member role
return {
showMembersList: true,
showPendingTab: false,
showSearch: true,
showDateColumn: true,
showRoleBadge: true,
membersGridCols: 'grid-cols-[1fr_auto]',
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
headerGridCols: 'grid-cols-[1fr_auto]',
showEditWorkspaceMenuItem: false,
workspaceMenuAction: 'leave',
workspaceMenuDisabledTooltip: null
}
}
/**
* Internal implementation of UI configuration composable.
*/
function useWorkspaceUIInternal() {
const store = useTeamWorkspaceStore()
// Tab management (shared UI state)
const activeTab = ref<string>('plan')
function setActiveTab(tab: string | number) {
activeTab.value = String(tab)
}
const workspaceType = computed<WorkspaceType>(
() => store.activeWorkspace?.type ?? 'personal'
)
const workspaceRole = computed<WorkspaceRole>(
() => store.activeWorkspace?.role ?? 'owner'
)
const permissions = computed<WorkspacePermissions>(() =>
getPermissions(workspaceType.value, workspaceRole.value)
)
const uiConfig = computed<WorkspaceUIConfig>(() =>
getUIConfig(workspaceType.value, workspaceRole.value)
)
return {
// Tab management
activeTab: computed(() => activeTab.value),
setActiveTab,
// Permissions and config
permissions,
uiConfig,
workspaceType,
workspaceRole
}
}
/**
* UI configuration composable derived from workspace state.
* Controls what UI elements are visible/enabled based on role and workspace type.
* Uses createSharedComposable to ensure tab state is shared across components.
*/
export const useWorkspaceUI = createSharedComposable(useWorkspaceUIInternal)

View File

@@ -0,0 +1,148 @@
import { WORKSPACE_STORAGE_KEYS } from '@/platform/auth/workspace/workspaceConstants'
/**
* Session manager for workspace context.
* Handles sessionStorage operations and page reloads for workspace switching.
*/
export const sessionManager = {
/**
* Get the current workspace ID from sessionStorage
*/
getCurrentWorkspaceId(): string | null {
try {
return sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE)
} catch {
return null
}
},
/**
* Set the current workspace ID in sessionStorage
*/
setCurrentWorkspaceId(workspaceId: string): void {
try {
sessionStorage.setItem(
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE,
workspaceId
)
} catch {
console.warn('Failed to set workspace ID in sessionStorage')
}
},
/**
* Clear the current workspace ID from sessionStorage
*/
clearCurrentWorkspaceId(): void {
try {
sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE)
} catch {
console.warn('Failed to clear workspace ID from sessionStorage')
}
},
/**
* Get the last workspace ID from localStorage (cross-session persistence)
*/
getLastWorkspaceId(): string | null {
try {
return localStorage.getItem(WORKSPACE_STORAGE_KEYS.LAST_WORKSPACE_ID)
} catch {
return null
}
},
/**
* Persist the last workspace ID to localStorage
*/
setLastWorkspaceId(workspaceId: string): void {
try {
localStorage.setItem(
WORKSPACE_STORAGE_KEYS.LAST_WORKSPACE_ID,
workspaceId
)
} catch {
console.warn('Failed to persist last workspace ID to localStorage')
}
},
/**
* Clear the last workspace ID from localStorage
*/
clearLastWorkspaceId(): void {
try {
localStorage.removeItem(WORKSPACE_STORAGE_KEYS.LAST_WORKSPACE_ID)
} catch {
console.warn('Failed to clear last workspace ID from localStorage')
}
},
/**
* Get the workspace token and expiry from sessionStorage
*/
getWorkspaceToken(): { token: string; expiresAt: number } | null {
try {
const token = sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.TOKEN)
const expiresAtStr = sessionStorage.getItem(
WORKSPACE_STORAGE_KEYS.EXPIRES_AT
)
if (!token || !expiresAtStr) return null
const expiresAt = parseInt(expiresAtStr, 10)
if (isNaN(expiresAt)) return null
return { token, expiresAt }
} catch {
return null
}
},
/**
* Store the workspace token and expiry in sessionStorage
*/
setWorkspaceToken(token: string, expiresAt: number): void {
try {
sessionStorage.setItem(WORKSPACE_STORAGE_KEYS.TOKEN, token)
sessionStorage.setItem(
WORKSPACE_STORAGE_KEYS.EXPIRES_AT,
expiresAt.toString()
)
} catch {
console.warn('Failed to set workspace token in sessionStorage')
}
},
/**
* Clear the workspace token from sessionStorage
*/
clearWorkspaceToken(): void {
try {
sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.TOKEN)
sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT)
} catch {
console.warn('Failed to clear workspace token from sessionStorage')
}
},
/**
* Switch workspace and reload the page.
* Clears the old workspace token before reload so fresh token is fetched.
* Code after calling this won't execute (page is gone).
*/
switchWorkspaceAndReload(workspaceId: string): void {
this.clearWorkspaceToken()
this.setCurrentWorkspaceId(workspaceId)
this.setLastWorkspaceId(workspaceId)
window.location.reload()
},
/**
* Clear workspace context and reload (e.g., after deletion).
* Falls back to personal workspace on next boot.
*/
clearAndReload(): void {
this.clearWorkspaceToken()
this.clearCurrentWorkspaceId()
window.location.reload()
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,780 @@
import { defineStore } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { TOKEN_REFRESH_BUFFER_MS } from '@/platform/auth/workspace/workspaceConstants'
import { sessionManager } from '../services/sessionManager'
import type {
ExchangeTokenResponse,
ListMembersParams,
Member,
PendingInvite as ApiPendingInvite,
WorkspaceWithRole
} from '../api/workspaceApi'
import { workspaceApi, WorkspaceApiError } from '../api/workspaceApi'
// Extended member type for UI (adds joinDate as Date)
export interface WorkspaceMember {
id: string
name: string
email: string
joinDate: Date
}
// Extended invite type for UI (adds dates as Date objects)
export interface PendingInvite {
id: string
email: string
token: string
inviteDate: Date
expiryDate: Date
}
type SubscriptionPlan = 'PRO_MONTHLY' | 'PRO_YEARLY' | null
interface WorkspaceState extends WorkspaceWithRole {
isSubscribed: boolean
subscriptionPlan: SubscriptionPlan
members: WorkspaceMember[]
pendingInvites: PendingInvite[]
}
type InitState = 'uninitialized' | 'loading' | 'ready' | 'error'
function mapApiMemberToWorkspaceMember(member: Member): WorkspaceMember {
return {
id: member.id,
name: member.name,
email: member.email,
joinDate: new Date(member.joined_at)
}
}
function mapApiInviteToPendingInvite(invite: ApiPendingInvite): PendingInvite {
return {
id: invite.id,
email: invite.email,
token: invite.token,
inviteDate: new Date(invite.invited_at),
expiryDate: new Date(invite.expires_at)
}
}
function createWorkspaceState(workspace: WorkspaceWithRole): WorkspaceState {
return {
...workspace,
isSubscribed: false,
subscriptionPlan: null,
members: [],
pendingInvites: []
}
}
// Workspace limits
const MAX_OWNED_WORKSPACES = 10
const MAX_WORKSPACE_MEMBERS = 50
export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
// ════════════════════════════════════════════════════════════
// STATE
// ════════════════════════════════════════════════════════════
const initState = ref<InitState>('uninitialized')
const workspaces = shallowRef<WorkspaceState[]>([])
const activeWorkspaceId = ref<string | null>(null)
const error = ref<Error | null>(null)
// Loading states for UI
const isCreating = ref(false)
const isDeleting = ref(false)
const isSwitching = ref(false)
const isFetchingWorkspaces = ref(false)
// Token refresh timer state
let refreshTimerId: ReturnType<typeof setTimeout> | null = null
// Request ID to prevent stale refresh operations from overwriting newer workspace contexts
let tokenRefreshRequestId = 0
// ════════════════════════════════════════════════════════════
// COMPUTED
// ════════════════════════════════════════════════════════════
const activeWorkspace = computed(
() => workspaces.value.find((w) => w.id === activeWorkspaceId.value) ?? null
)
const personalWorkspace = computed(
() => workspaces.value.find((w) => w.type === 'personal') ?? null
)
const isInPersonalWorkspace = computed(
() => activeWorkspace.value?.type === 'personal'
)
const sharedWorkspaces = computed(() =>
workspaces.value.filter((w) => w.type !== 'personal')
)
const ownedWorkspacesCount = computed(
() => workspaces.value.filter((w) => w.role === 'owner').length
)
const canCreateWorkspace = computed(
() => ownedWorkspacesCount.value < MAX_OWNED_WORKSPACES
)
const members = computed<WorkspaceMember[]>(
() => activeWorkspace.value?.members ?? []
)
const pendingInvites = computed<PendingInvite[]>(
() => activeWorkspace.value?.pendingInvites ?? []
)
const totalMemberSlots = computed(
() => members.value.length + pendingInvites.value.length
)
const isInviteLimitReached = computed(
() => totalMemberSlots.value >= MAX_WORKSPACE_MEMBERS
)
const workspaceId = computed(() => activeWorkspace.value?.id ?? null)
const workspaceName = computed(() => activeWorkspace.value?.name ?? '')
const isWorkspaceSubscribed = computed(
() => activeWorkspace.value?.isSubscribed ?? false
)
const subscriptionPlan = computed(
() => activeWorkspace.value?.subscriptionPlan ?? null
)
// ════════════════════════════════════════════════════════════
// INTERNAL HELPERS
// ════════════════════════════════════════════════════════════
function updateWorkspace(
workspaceId: string,
updates: Partial<WorkspaceState>
) {
const index = workspaces.value.findIndex((w) => w.id === workspaceId)
if (index === -1) return
const current = workspaces.value[index]
const updated = { ...current, ...updates }
workspaces.value = [
...workspaces.value.slice(0, index),
updated,
...workspaces.value.slice(index + 1)
]
}
function updateActiveWorkspace(updates: Partial<WorkspaceState>) {
if (!activeWorkspaceId.value) return
updateWorkspace(activeWorkspaceId.value, updates)
}
// ════════════════════════════════════════════════════════════
// TOKEN MANAGEMENT
// ════════════════════════════════════════════════════════════
function stopRefreshTimer(): void {
if (refreshTimerId !== null) {
clearTimeout(refreshTimerId)
refreshTimerId = null
}
}
function scheduleTokenRefresh(expiresAt: number): void {
stopRefreshTimer()
const now = Date.now()
const refreshAt = expiresAt - TOKEN_REFRESH_BUFFER_MS
const delay = Math.max(0, refreshAt - now)
refreshTimerId = setTimeout(() => {
void refreshWorkspaceToken()
}, delay)
}
/**
* Exchange Firebase token for workspace-scoped token.
* Stores the token in sessionStorage and schedules refresh.
*/
async function exchangeAndStoreToken(
workspaceId: string
): Promise<ExchangeTokenResponse> {
const response = await workspaceApi.exchangeToken(workspaceId)
const expiresAt = new Date(response.expires_at).getTime()
if (isNaN(expiresAt)) {
throw new Error('Invalid token expiry timestamp from server')
}
// Store token in sessionStorage
sessionManager.setWorkspaceToken(response.token, expiresAt)
// Schedule refresh before expiry
scheduleTokenRefresh(expiresAt)
return response
}
/**
* Refresh the workspace token.
* Called automatically before token expires.
* Includes retry logic for transient failures.
*/
async function refreshWorkspaceToken(): Promise<void> {
if (!activeWorkspaceId.value) return
const workspaceId = activeWorkspaceId.value
const capturedRequestId = tokenRefreshRequestId
const maxRetries = 3
const baseDelayMs = 1000
for (let attempt = 0; attempt <= maxRetries; attempt++) {
// Check if workspace context changed during refresh
if (capturedRequestId !== tokenRefreshRequestId) {
console.warn(
'[workspaceStore] Aborting stale token refresh: workspace context changed'
)
return
}
try {
await exchangeAndStoreToken(workspaceId)
return
} catch (err) {
const isApiError = err instanceof WorkspaceApiError
// Permanent errors - don't retry
const isPermanentError =
isApiError &&
(err.code === 'ACCESS_DENIED' ||
err.code === 'WORKSPACE_NOT_FOUND' ||
err.code === 'INVALID_FIREBASE_TOKEN' ||
err.code === 'NOT_AUTHENTICATED')
if (isPermanentError) {
if (capturedRequestId === tokenRefreshRequestId) {
console.error(
'[workspaceStore] Workspace access revoked or auth invalid:',
err
)
clearTokenContext()
}
return
}
// Transient errors - retry with backoff
if (attempt < maxRetries) {
const delay = baseDelayMs * Math.pow(2, attempt)
console.warn(
`[workspaceStore] Token refresh failed (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${delay}ms:`,
err
)
await new Promise((resolve) => setTimeout(resolve, delay))
continue
}
// All retries exhausted
if (capturedRequestId === tokenRefreshRequestId) {
console.error(
'[workspaceStore] Failed to refresh token after retries:',
err
)
clearTokenContext()
}
}
}
}
/**
* Clear token context (on auth failure or workspace switch).
*/
function clearTokenContext(): void {
tokenRefreshRequestId++
stopRefreshTimer()
sessionManager.clearWorkspaceToken()
}
/**
* Check if we have a valid token in sessionStorage (for page refresh).
* If valid, schedule refresh timer. If expired, return false.
*/
function initializeTokenFromSession(): boolean {
const tokenData = sessionManager.getWorkspaceToken()
if (!tokenData) return false
const { expiresAt } = tokenData
if (Date.now() >= expiresAt) {
// Token expired, clear it
sessionManager.clearWorkspaceToken()
return false
}
// Token still valid, schedule refresh
scheduleTokenRefresh(expiresAt)
return true
}
// ════════════════════════════════════════════════════════════
// INITIALIZATION
// ════════════════════════════════════════════════════════════
/**
* Initialize the workspace store.
* Fetches workspaces and resolves the active workspace from session/localStorage.
* Call once on app boot.
*/
async function initialize(): Promise<void> {
if (initState.value !== 'uninitialized') return
initState.value = 'loading'
isFetchingWorkspaces.value = true
error.value = null
try {
// 1. Fetch all workspaces
const response = await workspaceApi.list()
workspaces.value = response.workspaces.map(createWorkspaceState)
if (workspaces.value.length === 0) {
throw new Error('No workspaces available')
}
// 2. Determine active workspace (priority: sessionStorage > localStorage > personal)
let targetWorkspaceId: string | null = null
// Try sessionStorage first (page refresh)
const sessionId = sessionManager.getCurrentWorkspaceId()
if (sessionId && workspaces.value.some((w) => w.id === sessionId)) {
targetWorkspaceId = sessionId
}
// Try localStorage (cross-session persistence)
if (!targetWorkspaceId) {
const lastId = sessionManager.getLastWorkspaceId()
if (lastId && workspaces.value.some((w) => w.id === lastId)) {
targetWorkspaceId = lastId
}
}
// Fall back to personal workspace
if (!targetWorkspaceId) {
const personal = workspaces.value.find((w) => w.type === 'personal')
targetWorkspaceId = personal?.id ?? workspaces.value[0].id
}
// 3. Set active workspace
activeWorkspaceId.value = targetWorkspaceId
sessionManager.setCurrentWorkspaceId(targetWorkspaceId)
sessionManager.setLastWorkspaceId(targetWorkspaceId)
// 4. Initialize workspace token
// First check if we have a valid token from session (page refresh case)
const hasValidToken = initializeTokenFromSession()
if (!hasValidToken) {
// No valid token - exchange Firebase token for workspace token
try {
await exchangeAndStoreToken(targetWorkspaceId)
} catch (tokenError) {
// Log but don't fail initialization - API calls will fall back to Firebase token
console.error(
'[workspaceStore] Token exchange failed during init:',
tokenError
)
}
}
initState.value = 'ready'
} catch (e) {
error.value = e instanceof Error ? e : new Error('Unknown error')
initState.value = 'error'
throw e
} finally {
isFetchingWorkspaces.value = false
}
}
/**
* Re-fetch workspaces from API without changing active workspace.
*/
async function refreshWorkspaces(): Promise<void> {
isFetchingWorkspaces.value = true
try {
const response = await workspaceApi.list()
workspaces.value = response.workspaces.map(createWorkspaceState)
} finally {
isFetchingWorkspaces.value = false
}
}
// ════════════════════════════════════════════════════════════
// WORKSPACE ACTIONS
// ════════════════════════════════════════════════════════════
/**
* Switch to a different workspace.
* Sets session storage and reloads the page.
*/
async function switchWorkspace(workspaceId: string): Promise<void> {
if (workspaceId === activeWorkspaceId.value) return
// Invalidate any in-flight token refresh for the old workspace
clearTokenContext()
isSwitching.value = true
try {
// Verify workspace exists in our list (user has access)
const workspace = workspaces.value.find((w) => w.id === workspaceId)
if (!workspace) {
// Workspace not in list - try refetching in case it was added
await refreshWorkspaces()
const refreshedWorkspace = workspaces.value.find(
(w) => w.id === workspaceId
)
if (!refreshedWorkspace) {
throw new Error('Workspace not found or access denied')
}
}
// Success - switch and reload
sessionManager.switchWorkspaceAndReload(workspaceId)
// Code after this won't run (page reloads)
} catch (e) {
isSwitching.value = false
throw e
}
}
/**
* Create a new workspace and switch to it.
*/
async function createWorkspace(name: string): Promise<WorkspaceState> {
isCreating.value = true
try {
const newWorkspace = await workspaceApi.create({ name })
const workspaceState = createWorkspaceState(newWorkspace)
// Add to local list
workspaces.value = [...workspaces.value, workspaceState]
// Switch to new workspace (triggers reload)
sessionManager.switchWorkspaceAndReload(newWorkspace.id)
// Code after this won't run (page reloads)
return workspaceState
} catch (e) {
isCreating.value = false
throw e
}
}
/**
* Delete a workspace.
* If deleting active workspace, switches to personal.
*/
async function deleteWorkspace(workspaceId?: string): Promise<void> {
const targetId = workspaceId ?? activeWorkspaceId.value
if (!targetId) throw new Error('No workspace to delete')
const workspace = workspaces.value.find((w) => w.id === targetId)
if (!workspace) throw new Error('Workspace not found')
if (workspace.type === 'personal') {
throw new Error('Cannot delete personal workspace')
}
isDeleting.value = true
try {
await workspaceApi.delete(targetId)
if (targetId === activeWorkspaceId.value) {
// Deleted active workspace - go to personal
const personal = personalWorkspace.value
if (personal) {
sessionManager.switchWorkspaceAndReload(personal.id)
} else {
sessionManager.clearAndReload()
}
// Code after this won't run (page reloads)
} else {
// Deleted non-active workspace - just update local list
workspaces.value = workspaces.value.filter((w) => w.id !== targetId)
isDeleting.value = false
}
} catch (e) {
isDeleting.value = false
throw e
}
}
/**
* Rename a workspace. No reload needed.
*/
async function renameWorkspace(
workspaceId: string,
newName: string
): Promise<void> {
const updated = await workspaceApi.update(workspaceId, { name: newName })
updateWorkspace(workspaceId, { name: updated.name })
}
/**
* Update workspace name (convenience for current workspace).
*/
async function updateWorkspaceName(name: string): Promise<void> {
if (!activeWorkspaceId.value) {
throw new Error('No active workspace')
}
await renameWorkspace(activeWorkspaceId.value, name)
}
/**
* Leave the current workspace.
* Switches to personal workspace after leaving.
*/
async function leaveWorkspace(): Promise<void> {
const current = activeWorkspace.value
if (!current || current.type === 'personal') {
throw new Error('Cannot leave personal workspace')
}
await workspaceApi.leave()
// Go to personal workspace
const personal = personalWorkspace.value
if (personal) {
sessionManager.switchWorkspaceAndReload(personal.id)
} else {
sessionManager.clearAndReload()
}
// Code after this won't run (page reloads)
}
// ════════════════════════════════════════════════════════════
// MEMBER ACTIONS
// ════════════════════════════════════════════════════════════
/**
* Fetch members for the current workspace.
*/
async function fetchMembers(
params?: ListMembersParams
): Promise<WorkspaceMember[]> {
if (!activeWorkspaceId.value) return []
if (activeWorkspace.value?.type === 'personal') return []
const response = await workspaceApi.listMembers(params)
const members = response.members.map(mapApiMemberToWorkspaceMember)
updateActiveWorkspace({ members })
return members
}
/**
* Remove a member from the current workspace.
*/
async function removeMember(userId: string): Promise<void> {
await workspaceApi.removeMember(userId)
const current = activeWorkspace.value
if (current) {
updateActiveWorkspace({
members: current.members.filter((m) => m.id !== userId)
})
}
}
// ════════════════════════════════════════════════════════════
// INVITE ACTIONS
// ════════════════════════════════════════════════════════════
/**
* Fetch pending invites for the current workspace.
*/
async function fetchPendingInvites(): Promise<PendingInvite[]> {
if (!activeWorkspaceId.value) return []
if (activeWorkspace.value?.type === 'personal') return []
const response = await workspaceApi.listInvites()
const invites = response.invites.map(mapApiInviteToPendingInvite)
updateActiveWorkspace({ pendingInvites: invites })
return invites
}
/**
* Create an invite for the current workspace.
*/
async function createInvite(email: string): Promise<PendingInvite> {
const response = await workspaceApi.createInvite({ email })
const invite = mapApiInviteToPendingInvite(response)
const current = activeWorkspace.value
if (current) {
updateActiveWorkspace({
pendingInvites: [...current.pendingInvites, invite]
})
}
return invite
}
/**
* Revoke a pending invite.
*/
async function revokeInvite(inviteId: string): Promise<void> {
await workspaceApi.revokeInvite(inviteId)
const current = activeWorkspace.value
if (current) {
updateActiveWorkspace({
pendingInvites: current.pendingInvites.filter((i) => i.id !== inviteId)
})
}
}
/**
* Accept a workspace invite.
* Returns workspace info so UI can offer "View Workspace" button.
*/
async function acceptInvite(
token: string
): Promise<{ workspaceId: string; workspaceName: string }> {
const response = await workspaceApi.acceptInvite(token)
// Refresh workspace list to include newly joined workspace
await refreshWorkspaces()
return {
workspaceId: response.workspace_id,
workspaceName: response.workspace_name
}
}
// ════════════════════════════════════════════════════════════
// INVITE LINK HELPERS
// ════════════════════════════════════════════════════════════
function buildInviteLink(token: string): string {
const baseUrl = window.location.origin
return `${baseUrl}?invite=${encodeURIComponent(token)}`
}
/**
* Get the invite link for a pending invite.
*/
function getInviteLink(inviteId: string): string | null {
const invite = activeWorkspace.value?.pendingInvites.find(
(i) => i.id === inviteId
)
return invite ? buildInviteLink(invite.token) : null
}
/**
* Create an invite link for a given email.
*/
async function createInviteLink(email: string): Promise<string> {
const invite = await createInvite(email)
return buildInviteLink(invite.token)
}
/**
* Copy an invite link to clipboard.
*/
async function copyInviteLink(inviteId: string): Promise<string> {
const invite = activeWorkspace.value?.pendingInvites.find(
(i) => i.id === inviteId
)
if (!invite) {
throw new Error('Invite not found')
}
const inviteLink = buildInviteLink(invite.token)
await navigator.clipboard.writeText(inviteLink)
return inviteLink
}
// ════════════════════════════════════════════════════════════
// SUBSCRIPTION (placeholder for future integration)
// ════════════════════════════════════════════════════════════
function subscribeWorkspace(plan: SubscriptionPlan = 'PRO_MONTHLY') {
console.warn(plan, 'Billing endpoint has not been added yet.')
}
// ════════════════════════════════════════════════════════════
// CLEANUP
// ════════════════════════════════════════════════════════════
/**
* Clean up store resources (timers, etc.).
* Call when the store is no longer needed.
*/
function destroy(): void {
clearTokenContext()
}
// ════════════════════════════════════════════════════════════
// RETURN
// ════════════════════════════════════════════════════════════
return {
// State
initState,
workspaces,
activeWorkspaceId,
error,
isCreating,
isDeleting,
isSwitching,
isFetchingWorkspaces,
// Computed
activeWorkspace,
personalWorkspace,
isInPersonalWorkspace,
sharedWorkspaces,
ownedWorkspacesCount,
canCreateWorkspace,
members,
pendingInvites,
totalMemberSlots,
isInviteLimitReached,
workspaceId,
workspaceName,
isWorkspaceSubscribed,
subscriptionPlan,
// Initialization & Cleanup
initialize,
destroy,
refreshWorkspaces,
// Workspace Actions
switchWorkspace,
createWorkspace,
deleteWorkspace,
renameWorkspace,
updateWorkspaceName,
leaveWorkspace,
// Member Actions
fetchMembers,
removeMember,
// Invite Actions
fetchPendingInvites,
createInvite,
revokeInvite,
acceptInvite,
getInviteLink,
createInviteLink,
copyInviteLink,
// Subscription
subscribeWorkspace
}
})

View File

@@ -86,6 +86,10 @@ installPreservedQueryTracker(router, [
{
namespace: PRESERVED_QUERY_NAMESPACES.TEMPLATE,
keys: ['template', 'source', 'mode']
},
{
namespace: PRESERVED_QUERY_NAMESPACES.INVITE,
keys: ['invite']
}
])
@@ -178,6 +182,24 @@ if (isCloud) {
})
}
// Initialize workspace context for logged-in users navigating to root
// This must happen before the app loads to ensure workspace context is ready
if (to.path === '/' && flags.teamWorkspacesEnabled) {
const { useTeamWorkspaceStore } =
await import('@/platform/workspace/stores/teamWorkspaceStore')
const workspaceStore = useTeamWorkspaceStore()
if (workspaceStore.initState === 'uninitialized') {
try {
await workspaceStore.initialize()
} catch (error) {
console.error('Workspace initialization failed:', error)
// Continue anyway - workspace features will be degraded
}
}
}
// User is logged in - check if they need onboarding (when enabled)
// For root path, check actual user status to handle waitlisted users
if (!isElectron() && isLoggedIn && to.path === '/') {

View File

@@ -102,6 +102,7 @@ export const useDialogService = () => {
| 'user'
| 'credits'
| 'subscription'
| 'workspace'
) {
const props = panel ? { props: { defaultPanel: panel } } : undefined
@@ -519,6 +520,113 @@ export const useDialogService = () => {
show()
}
// Workspace dialogs - dynamically imported to avoid bundling when feature flag is off
const workspaceDialogPt = {
headless: true,
pt: {
header: { class: 'p-0! hidden' },
content: { class: 'p-0! m-0! rounded-2xl' },
root: { class: 'rounded-2xl' }
}
} as const
async function showLeaveWorkspaceDialog() {
const { default: component } =
await import('@/components/dialog/content/workspace/LeaveWorkspaceDialogContent.vue')
return dialogStore.showDialog({
key: 'leave-workspace',
component,
dialogComponentProps: workspaceDialogPt
})
}
async function showDeleteWorkspaceDialog(options?: {
workspaceId?: string
workspaceName?: string
}) {
const { default: component } =
await import('@/components/dialog/content/workspace/DeleteWorkspaceDialogContent.vue')
return dialogStore.showDialog({
key: 'delete-workspace',
component,
props: options,
dialogComponentProps: workspaceDialogPt
})
}
async function showRemoveMemberDialog(memberId: string) {
const { default: component } =
await import('@/components/dialog/content/workspace/RemoveMemberDialogContent.vue')
return dialogStore.showDialog({
key: 'remove-member',
component,
props: { memberId },
dialogComponentProps: workspaceDialogPt
})
}
async function showRevokeInviteDialog(inviteId: string) {
const { default: component } =
await import('@/components/dialog/content/workspace/RevokeInviteDialogContent.vue')
return dialogStore.showDialog({
key: 'revoke-invite',
component,
props: { inviteId },
dialogComponentProps: workspaceDialogPt
})
}
async function showInviteMemberDialog() {
const { default: component } =
await import('@/components/dialog/content/workspace/InviteMemberDialogContent.vue')
return dialogStore.showDialog({
key: 'invite-member',
component,
dialogComponentProps: {
...workspaceDialogPt,
pt: {
...workspaceDialogPt.pt,
root: { class: 'rounded-2xl max-w-[512px] w-full' }
}
}
})
}
async function showCreateWorkspaceDialog(
onConfirm?: (name: string) => void | Promise<void>
) {
const { default: component } =
await import('@/components/dialog/content/workspace/CreateWorkspaceDialogContent.vue')
return dialogStore.showDialog({
key: 'create-workspace',
component,
props: { onConfirm },
dialogComponentProps: {
...workspaceDialogPt,
pt: {
...workspaceDialogPt.pt,
root: { class: 'rounded-2xl max-w-[400px] w-full' }
}
}
})
}
async function showEditWorkspaceDialog() {
const { default: component } =
await import('@/components/dialog/content/workspace/EditWorkspaceDialogContent.vue')
return dialogStore.showDialog({
key: 'edit-workspace',
component,
dialogComponentProps: {
...workspaceDialogPt,
pt: {
...workspaceDialogPt.pt,
root: { class: 'rounded-2xl max-w-[400px] w-full' }
}
}
})
}
return {
showLoadWorkflowWarning,
showMissingModelsWarning,
@@ -530,6 +638,13 @@ export const useDialogService = () => {
showSubscriptionRequiredDialog,
showTopUpCreditsDialog,
showUpdatePasswordDialog,
showLeaveWorkspaceDialog,
showDeleteWorkspaceDialog,
showRemoveMemberDialog,
showRevokeInviteDialog,
showInviteMemberDialog,
showCreateWorkspaceDialog,
showEditWorkspaceDialog,
showExtensionDialog,
prompt,
showErrorDialog,

View File

@@ -25,12 +25,12 @@ import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { WORKSPACE_STORAGE_KEYS } from '@/platform/auth/workspace/workspaceConstants'
import { isCloud } from '@/platform/distribution/types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import type { AuthHeader } from '@/types/authTypes'
import type { operations } from '@/types/comfyRegistryTypes'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
type CreditPurchaseResponse =
operations['InitiateCreditPurchase']['responses']['201']['content']['application/json']
@@ -58,6 +58,8 @@ export class FirebaseAuthStoreError extends Error {
}
export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
const { flags } = useFeatureFlags()
// State
const loading = ref(false)
const currentUser = ref<User | null>(null)
@@ -173,7 +175,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
* - null if no authentication method is available
*/
const getAuthHeader = async (): Promise<AuthHeader | null> => {
if (remoteConfig.value.team_workspaces_enabled) {
if (flags.teamWorkspacesEnabled) {
const workspaceToken = sessionStorage.getItem(
WORKSPACE_STORAGE_KEYS.TOKEN
)
@@ -201,10 +203,19 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
return useApiKeyAuthStore().getAuthHeader()
}
/**
* Returns Firebase auth header for user-scoped endpoints (e.g., /customers/*).
* Use this for endpoints that need user identity, not workspace context.
*/
const getFirebaseAuthHeader = async (): Promise<AuthHeader | null> => {
const token = await getIdToken()
return token ? { Authorization: `Bearer ${token}` } : null
}
const fetchBalance = async (): Promise<GetCustomerBalanceResponse | null> => {
isFetchingBalance.value = true
try {
const authHeader = await getAuthHeader()
const authHeader = await getFirebaseAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(
t('toastMessages.userNotAuthenticated')
@@ -242,7 +253,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
}
const createCustomer = async (): Promise<CreateCustomerResponse> => {
const authHeader = await getAuthHeader()
const authHeader = await getFirebaseAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
@@ -404,7 +415,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
const addCredits = async (
requestBodyContent: CreditPurchasePayload
): Promise<CreditPurchaseResponse> => {
const authHeader = await getAuthHeader()
const authHeader = await getFirebaseAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
@@ -444,21 +455,19 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
const accessBillingPortal = async (
targetTier?: BillingPortalTargetTier
): Promise<AccessBillingPortalResponse> => {
const authHeader = await getAuthHeader()
const authHeader = await getFirebaseAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const requestBody = targetTier ? { target_tier: targetTier } : undefined
const response = await fetch(buildApiUrl('/customers/billing'), {
method: 'POST',
headers: {
...authHeader,
'Content-Type': 'application/json'
},
...(requestBody && {
body: JSON.stringify(requestBody)
...(targetTier && {
body: JSON.stringify({ target_tier: targetTier })
})
})

View File

@@ -1,373 +0,0 @@
import { defineStore } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { z } from 'zod'
import { fromZodError } from 'zod-validation-error'
import { t } from '@/i18n'
import {
TOKEN_REFRESH_BUFFER_MS,
WORKSPACE_STORAGE_KEYS
} from '@/platform/auth/workspace/workspaceConstants'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { api } from '@/scripts/api'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { AuthHeader } from '@/types/authTypes'
import type { WorkspaceWithRole } from '@/platform/auth/workspace/workspaceTypes'
const WorkspaceWithRoleSchema = z.object({
id: z.string(),
name: z.string(),
type: z.enum(['personal', 'team']),
role: z.enum(['owner', 'member'])
})
const WorkspaceTokenResponseSchema = z.object({
token: z.string(),
expires_at: z.string(),
workspace: z.object({
id: z.string(),
name: z.string(),
type: z.enum(['personal', 'team'])
}),
role: z.enum(['owner', 'member']),
permissions: z.array(z.string())
})
export class WorkspaceAuthError extends Error {
constructor(
message: string,
public readonly code?: string
) {
super(message)
this.name = 'WorkspaceAuthError'
}
}
export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
// State
const currentWorkspace = shallowRef<WorkspaceWithRole | null>(null)
const workspaceToken = ref<string | null>(null)
const isLoading = ref(false)
const error = ref<Error | null>(null)
// Timer state
let refreshTimerId: ReturnType<typeof setTimeout> | null = null
// Request ID to prevent stale refresh operations from overwriting newer workspace contexts
let refreshRequestId = 0
// Getters
const isAuthenticated = computed(
() => currentWorkspace.value !== null && workspaceToken.value !== null
)
// Private helpers
function stopRefreshTimer(): void {
if (refreshTimerId !== null) {
clearTimeout(refreshTimerId)
refreshTimerId = null
}
}
function scheduleTokenRefresh(expiresAt: number): void {
stopRefreshTimer()
const now = Date.now()
const refreshAt = expiresAt - TOKEN_REFRESH_BUFFER_MS
const delay = Math.max(0, refreshAt - now)
refreshTimerId = setTimeout(() => {
void refreshToken()
}, delay)
}
function persistToSession(
workspace: WorkspaceWithRole,
token: string,
expiresAt: number
): void {
try {
sessionStorage.setItem(
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE,
JSON.stringify(workspace)
)
sessionStorage.setItem(WORKSPACE_STORAGE_KEYS.TOKEN, token)
sessionStorage.setItem(
WORKSPACE_STORAGE_KEYS.EXPIRES_AT,
expiresAt.toString()
)
} catch {
console.warn('Failed to persist workspace context to sessionStorage')
}
}
function clearSessionStorage(): void {
try {
sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE)
sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.TOKEN)
sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT)
} catch {
console.warn('Failed to clear workspace context from sessionStorage')
}
}
// Actions
function init(): void {
initializeFromSession()
}
function destroy(): void {
stopRefreshTimer()
}
function initializeFromSession(): boolean {
if (!remoteConfig.value.team_workspaces_enabled) {
return false
}
try {
const workspaceJson = sessionStorage.getItem(
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE
)
const token = sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.TOKEN)
const expiresAtStr = sessionStorage.getItem(
WORKSPACE_STORAGE_KEYS.EXPIRES_AT
)
if (!workspaceJson || !token || !expiresAtStr) {
return false
}
const expiresAt = parseInt(expiresAtStr, 10)
if (isNaN(expiresAt) || expiresAt <= Date.now()) {
clearSessionStorage()
return false
}
const parsedWorkspace = JSON.parse(workspaceJson)
const parseResult = WorkspaceWithRoleSchema.safeParse(parsedWorkspace)
if (!parseResult.success) {
clearSessionStorage()
return false
}
currentWorkspace.value = parseResult.data
workspaceToken.value = token
error.value = null
scheduleTokenRefresh(expiresAt)
return true
} catch {
clearSessionStorage()
return false
}
}
async function switchWorkspace(workspaceId: string): Promise<void> {
if (!remoteConfig.value.team_workspaces_enabled) {
return
}
// Only increment request ID when switching to a different workspace
// This invalidates stale refresh operations for the old workspace
// but allows refresh operations for the same workspace to complete
if (currentWorkspace.value?.id !== workspaceId) {
refreshRequestId++
}
isLoading.value = true
error.value = null
try {
const firebaseAuthStore = useFirebaseAuthStore()
const firebaseToken = await firebaseAuthStore.getIdToken()
if (!firebaseToken) {
throw new WorkspaceAuthError(
t('workspaceAuth.errors.notAuthenticated'),
'NOT_AUTHENTICATED'
)
}
const response = await fetch(api.apiURL('/auth/token'), {
method: 'POST',
headers: {
Authorization: `Bearer ${firebaseToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ workspace_id: workspaceId })
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
const message = errorData.message || response.statusText
if (response.status === 401) {
throw new WorkspaceAuthError(
t('workspaceAuth.errors.invalidFirebaseToken'),
'INVALID_FIREBASE_TOKEN'
)
}
if (response.status === 403) {
throw new WorkspaceAuthError(
t('workspaceAuth.errors.accessDenied'),
'ACCESS_DENIED'
)
}
if (response.status === 404) {
throw new WorkspaceAuthError(
t('workspaceAuth.errors.workspaceNotFound'),
'WORKSPACE_NOT_FOUND'
)
}
throw new WorkspaceAuthError(
t('workspaceAuth.errors.tokenExchangeFailed', { error: message }),
'TOKEN_EXCHANGE_FAILED'
)
}
const rawData = await response.json()
const parseResult = WorkspaceTokenResponseSchema.safeParse(rawData)
if (!parseResult.success) {
throw new WorkspaceAuthError(
t('workspaceAuth.errors.tokenExchangeFailed', {
error: fromZodError(parseResult.error).message
}),
'TOKEN_EXCHANGE_FAILED'
)
}
const data = parseResult.data
const expiresAt = new Date(data.expires_at).getTime()
if (isNaN(expiresAt)) {
throw new WorkspaceAuthError(
t('workspaceAuth.errors.tokenExchangeFailed', {
error: 'Invalid expiry timestamp'
}),
'TOKEN_EXCHANGE_FAILED'
)
}
const workspaceWithRole: WorkspaceWithRole = {
...data.workspace,
role: data.role
}
currentWorkspace.value = workspaceWithRole
workspaceToken.value = data.token
persistToSession(workspaceWithRole, data.token, expiresAt)
scheduleTokenRefresh(expiresAt)
} catch (err) {
error.value = err instanceof Error ? err : new Error(String(err))
throw error.value
} finally {
isLoading.value = false
}
}
async function refreshToken(): Promise<void> {
if (!currentWorkspace.value) {
return
}
const workspaceId = currentWorkspace.value.id
// Capture the current request ID to detect if workspace context changed during refresh
const capturedRequestId = refreshRequestId
const maxRetries = 3
const baseDelayMs = 1000
for (let attempt = 0; attempt <= maxRetries; attempt++) {
// Check if workspace context changed since refresh started (user switched workspaces)
if (capturedRequestId !== refreshRequestId) {
console.warn(
'Aborting stale token refresh: workspace context changed during refresh'
)
return
}
try {
await switchWorkspace(workspaceId)
return
} catch (err) {
const isAuthError = err instanceof WorkspaceAuthError
const isPermanentError =
isAuthError &&
(err.code === 'ACCESS_DENIED' ||
err.code === 'WORKSPACE_NOT_FOUND' ||
err.code === 'INVALID_FIREBASE_TOKEN' ||
err.code === 'NOT_AUTHENTICATED')
if (isPermanentError) {
// Only clear context if this refresh is still for the current workspace
if (capturedRequestId === refreshRequestId) {
console.error('Workspace access revoked or auth invalid:', err)
clearWorkspaceContext()
}
return
}
const isTransientError =
isAuthError && err.code === 'TOKEN_EXCHANGE_FAILED'
if (isTransientError && attempt < maxRetries) {
const delay = baseDelayMs * Math.pow(2, attempt)
console.warn(
`Token refresh failed (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${delay}ms:`,
err
)
await new Promise((resolve) => setTimeout(resolve, delay))
continue
}
// Only clear context if this refresh is still for the current workspace
if (capturedRequestId === refreshRequestId) {
console.error('Failed to refresh workspace token after retries:', err)
clearWorkspaceContext()
}
}
}
}
function getWorkspaceAuthHeader(): AuthHeader | null {
if (!workspaceToken.value) {
return null
}
return {
Authorization: `Bearer ${workspaceToken.value}`
}
}
function clearWorkspaceContext(): void {
// Increment request ID to invalidate any in-flight stale refresh operations
refreshRequestId++
stopRefreshTimer()
currentWorkspace.value = null
workspaceToken.value = null
error.value = null
clearSessionStorage()
}
return {
// State
currentWorkspace,
workspaceToken,
isLoading,
error,
// Getters
isAuthenticated,
// Actions
init,
destroy,
initializeFromSession,
switchWorkspace,
refreshToken,
getWorkspaceAuthHeader,
clearWorkspaceContext
}
})

View File

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