feat: workspace switcher and misc

This commit is contained in:
--list
2026-01-17 00:30:05 -08:00
parent 99d91dd53b
commit bc698fb746
34 changed files with 2581 additions and 1889 deletions

View File

@@ -17,7 +17,7 @@ const { workspaceName } = defineProps<{
workspaceName: string
}>()
const letter = computed(() => workspaceName.charAt(0).toUpperCase())
const letter = computed(() => workspaceName?.charAt(0)?.toUpperCase() ?? '?')
const gradient = computed(() => {
const seed = letter.value.charCodeAt(0)

View File

@@ -237,12 +237,12 @@
class="flex size-8 shrink-0 items-center justify-center rounded-full bg-secondary-background"
>
<span class="text-sm font-bold text-base-foreground">
{{ invite.name.charAt(0).toUpperCase() }}
{{ getInviteInitial(invite.email) }}
</span>
</div>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<span class="text-sm text-base-foreground">
{{ invite.name }}
{{ getInviteDisplayName(invite.email) }}
</span>
<span class="text-sm text-muted-foreground">
{{ invite.email }}
@@ -310,7 +310,9 @@
</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'
@@ -318,33 +320,31 @@ 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/composables/useWorkspace'
import { useWorkspace } from '@/platform/workspace/composables/useWorkspace'
} from '@/platform/workspace/stores/workspaceStore'
import { useWorkspaceStore } from '@/platform/workspace/stores/workspaceStore'
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 = useWorkspaceStore()
const {
members,
pendingInvites,
fetchMembers,
fetchPendingInvites,
copyInviteLink,
revokeInvite,
isPersonalWorkspace,
permissions,
uiConfig,
workspaceRole
} = useWorkspace()
isInPersonalWorkspace: isPersonalWorkspace
} = storeToRefs(workspaceStore)
const { fetchMembers, fetchPendingInvites, copyInviteLink } = workspaceStore
const { permissions, uiConfig, workspaceRole } = useWorkspaceUI()
const searchQuery = ref('')
const activeView = ref<'active' | 'pending'>('active')
@@ -354,6 +354,14 @@ 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'),
@@ -416,10 +424,8 @@ const filteredPendingInvites = computed(() => {
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter(
(invite) =>
invite.name.toLowerCase().includes(query) ||
invite.email.toLowerCase().includes(query)
result = result.filter((invite) =>
invite.email.toLowerCase().includes(query)
)
}
@@ -446,23 +452,32 @@ function formatDate(date: Date): string {
return d(date, { dateStyle: 'medium' })
}
function handleCopyInviteLink(invite: PendingInvite) {
copyInviteLink(invite.id)
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(() => {
revokeInvite(invite.id)
})
showRevokeInviteDialog(invite.id)
}
function handleCreateWorkspace() {
showCreateWorkspaceDialog()
}
function handleRemoveMember(_member: WorkspaceMember) {
showRemoveMemberDialog(() => {
// TODO: Implement actual remove member API call
})
function handleRemoveMember(member: WorkspaceMember) {
showRemoveMemberDialog(member.id)
}
</script>

View File

@@ -85,6 +85,7 @@
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import Menu from 'primevue/menu'
import Tab from 'primevue/tab'
import TabList from 'primevue/tablist'
@@ -98,8 +99,8 @@ import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import MembersPanelContent from '@/components/dialog/content/setting/MembersPanelContent.vue'
import Button from '@/components/ui/button/Button.vue'
import SubscriptionPanelContent from '@/platform/cloud/subscription/components/SubscriptionPanelContent.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useWorkspace } from '@/platform/workspace/composables/useWorkspace'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useWorkspaceStore } from '@/platform/workspace/stores/workspaceStore'
import { useDialogService } from '@/services/dialogService'
const { defaultTab = 'plan' } = defineProps<{
@@ -112,19 +113,12 @@ const {
showDeleteWorkspaceDialog,
showInviteMemberDialog
} = useDialogService()
const { isActiveSubscription } = useSubscription()
const {
activeTab,
setActiveTab,
workspaceName,
workspaceRole,
members,
fetchMembers,
fetchPendingInvites,
permissions,
uiConfig,
isInviteLimitReached
} = useWorkspace()
const workspaceStore = useWorkspaceStore()
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)
@@ -140,10 +134,12 @@ function handleDeleteWorkspace() {
})
}
// 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' &&
isActiveSubscription.value
isWorkspaceSubscribed.value
)
const deleteTooltip = computed(() => {

View File

@@ -10,8 +10,10 @@
</template>
<script setup lang="ts">
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import { useWorkspace } from '@/platform/workspace/composables/useWorkspace'
import { storeToRefs } from 'pinia'
const { workspaceName } = useWorkspace()
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import { useWorkspaceStore } from '@/platform/workspace/stores/workspaceStore'
const { workspaceName } = storeToRefs(useWorkspaceStore())
</script>

View File

@@ -60,16 +60,20 @@
<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 { useWorkspaceStore } from '@/platform/workspace/stores/workspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const { onConfirm } = defineProps<{
onConfirm: (name: string) => void | Promise<void>
onConfirm?: (name: string) => void | Promise<void>
}>()
const { t } = useI18n()
const dialogStore = useDialogStore()
const toast = useToast()
const workspaceStore = useWorkspaceStore()
const loading = ref(false)
const workspaceName = ref('')
@@ -88,10 +92,19 @@ async function onCreate() {
if (!isValidName.value) return
loading.value = true
try {
await onConfirm(workspaceName.value.trim())
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({
group: 'workspace-created'
severity: 'error',
summary: t('workspacePanel.toast.failedToCreateWorkspace'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
})
} finally {
loading.value = false

View File

@@ -21,7 +21,13 @@
<!-- Body -->
<div class="px-4 py-4">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.deleteDialog.message') }}
{{
workspaceName
? $t('workspacePanel.deleteDialog.messageWithName', {
name: workspaceName
})
: $t('workspacePanel.deleteDialog.message')
}}
</p>
</div>
@@ -38,16 +44,23 @@
</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 { useWorkspaceStore } from '@/platform/workspace/stores/workspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const { onConfirm } = defineProps<{
onConfirm: () => void | Promise<void>
const { workspaceId, workspaceName } = defineProps<{
workspaceId?: string
workspaceName?: string
}>()
const { t } = useI18n()
const toast = useToast()
const dialogStore = useDialogStore()
const workspaceStore = useWorkspaceStore()
const loading = ref(false)
function onCancel() {
@@ -57,8 +70,18 @@ function onCancel() {
async function onDelete() {
loading.value = true
try {
await onConfirm()
// 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
}

View File

@@ -52,16 +52,20 @@
</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 { useWorkspace } from '@/platform/workspace/composables/useWorkspace'
import { useWorkspaceStore } from '@/platform/workspace/stores/workspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const { t } = useI18n()
const toast = useToast()
const dialogStore = useDialogStore()
const { workspaceName, updateWorkspaceName } = useWorkspace()
const workspaceStore = useWorkspaceStore()
const loading = ref(false)
const newWorkspaceName = ref(workspaceName.value)
const newWorkspaceName = ref(workspaceStore.workspaceName)
const isValidName = computed(() => {
const name = newWorkspaceName.value.trim()
@@ -77,8 +81,22 @@ async function onSave() {
if (!isValidName.value) return
loading.value = true
try {
updateWorkspaceName(newWorkspaceName.value.trim())
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
}

View File

@@ -117,7 +117,7 @@ import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useWorkspace } from '@/platform/workspace/composables/useWorkspace'
import { useWorkspaceStore } from '@/platform/workspace/stores/workspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const { onConfirm } = defineProps<{
@@ -126,7 +126,7 @@ const { onConfirm } = defineProps<{
const dialogStore = useDialogStore()
const toast = useToast()
const { createInviteLink } = useWorkspace()
const workspaceStore = useWorkspaceStore()
const loading = ref(false)
const email = ref('')
@@ -146,7 +146,7 @@ async function onCreateLink() {
if (!isValidEmail.value) return
loading.value = true
try {
generatedLink.value = await createInviteLink(email.value)
generatedLink.value = await workspaceStore.createInviteLink(email.value)
step.value = 'link'
await onConfirm(email.value)
} finally {

View File

@@ -38,16 +38,18 @@
</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 { useWorkspaceStore } from '@/platform/workspace/stores/workspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const { onConfirm } = defineProps<{
onConfirm: () => void | Promise<void>
}>()
const { t } = useI18n()
const toast = useToast()
const dialogStore = useDialogStore()
const workspaceStore = useWorkspaceStore()
const loading = ref(false)
function onCancel() {
@@ -57,8 +59,18 @@ function onCancel() {
async function onLeave() {
loading.value = true
try {
await onConfirm()
// 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
}

View File

@@ -41,13 +41,15 @@
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useWorkspaceStore } from '@/platform/workspace/stores/workspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const { onConfirm } = defineProps<{
onConfirm: () => void | Promise<void>
const { memberId } = defineProps<{
memberId: string
}>()
const dialogStore = useDialogStore()
const workspaceStore = useWorkspaceStore()
const loading = ref(false)
function onCancel() {
@@ -57,7 +59,7 @@ function onCancel() {
async function onRemove() {
loading.value = true
try {
await onConfirm()
await workspaceStore.removeMember(memberId)
dialogStore.closeDialog({ key: 'remove-member' })
} finally {
loading.value = false

View File

@@ -41,13 +41,15 @@
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useWorkspaceStore } from '@/platform/workspace/stores/workspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const { onConfirm } = defineProps<{
onConfirm: () => void | Promise<void>
const { inviteId } = defineProps<{
inviteId: string
}>()
const dialogStore = useDialogStore()
const workspaceStore = useWorkspaceStore()
const loading = ref(false)
function onCancel() {
@@ -57,7 +59,7 @@ function onCancel() {
async function onRevoke() {
loading.value = true
try {
await onConfirm()
await workspaceStore.revokeInvite(inviteId)
dialogStore.closeDialog({ key: 'revoke-invite' })
} finally {
loading.value = false

View File

@@ -40,13 +40,14 @@
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import Popover from 'primevue/popover'
import { ref } from 'vue'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useWorkspace } from '@/platform/workspace/composables/useWorkspace'
import { useWorkspaceStore } from '@/platform/workspace/stores/workspaceStore'
import { cn } from '@/utils/tailwindUtil'
import CurrentUserPopover from './CurrentUserPopover.vue'
@@ -57,7 +58,7 @@ const { showArrow = true, compact = false } = defineProps<{
}>()
const { isLoggedIn } = useCurrentUser()
const { workspaceName } = useWorkspace()
const { workspaceName } = storeToRefs(useWorkspaceStore())
const popover = ref<InstanceType<typeof Popover> | null>(null)

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

@@ -43,10 +43,10 @@
workspaceName
}}</span>
<div
v-if="subscriptionTierName"
v-if="workspaceTierName"
class="shrink-0 rounded bg-secondary-background-hover px-1.5 py-0.5 text-xs"
>
{{ subscriptionTierName }}
{{ workspaceTierName }}
</div>
<span v-else class="shrink-0 text-xs text-muted-foreground">
{{ $t('workspaceSwitcher.subscribe') }}
@@ -112,7 +112,7 @@
size="sm"
class="w-full"
data-testid="subscribe-button"
@click="handleOpenPlansAndPricing"
@click="handleOpenWorkspaceSettings"
>
{{ $t('subscription.subscribeNow') }}
</Button>
@@ -219,6 +219,7 @@
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import Divider from 'primevue/divider'
import Popover from 'primevue/popover'
import Skeleton from 'primevue/skeleton'
@@ -238,16 +239,19 @@ import { useSubscription } from '@/platform/cloud/subscription/composables/useSu
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkspace } from '@/platform/workspace/composables/useWorkspace'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useWorkspaceStore } from '@/platform/workspace/stores/workspaceStore'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const workspaceStore = useWorkspaceStore()
const {
workspaceName,
workspaceRole,
isPersonalWorkspace,
isWorkspaceSubscribed
} = useWorkspace()
isInPersonalWorkspace: isPersonalWorkspace,
isWorkspaceSubscribed,
subscriptionPlan
} = storeToRefs(workspaceStore)
const { workspaceRole } = useWorkspaceUI()
const workspaceSwitcherPopover = ref<InstanceType<typeof Popover> | null>(null)
const emit = defineEmits<{
@@ -261,14 +265,9 @@ const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
const authActions = useFirebaseAuthActions()
const authStore = useFirebaseAuthStore()
const dialogService = useDialogService()
const {
isActiveSubscription,
subscriptionTierName,
subscriptionTier,
fetchStatus
} = useSubscription()
const { isActiveSubscription, fetchStatus } = useSubscription()
const subscriptionDialog = useSubscriptionDialog()
const { locale } = useI18n()
const { locale, t } = useI18n()
const formattedBalance = computed(() => {
const cents =
@@ -285,11 +284,23 @@ const formattedBalance = computed(() => {
})
})
// 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(() => {
const tier = subscriptionTier.value
return (
tier === 'FOUNDERS_EDITION' || tier === 'STANDARD' || tier === 'CREATOR'
)
// For workspace-based subscriptions, can upgrade if not on highest tier
return isWorkspaceSubscribed.value && subscriptionPlan.value !== null
})
// Menu visibility based on role

View File

@@ -1,42 +1,73 @@
<template>
<div class="flex w-80 flex-col overflow-hidden rounded-lg">
<div class="flex flex-col overflow-y-auto">
<template
v-for="workspace in availableWorkspaces"
:key="workspace.id ?? 'personal'"
>
<div class="border-b border-border-default p-2">
<button
:class="
cn(
'flex h-[54px] w-full cursor-pointer items-center gap-2 rounded px-2 py-4 border-none bg-transparent',
'hover:bg-secondary-background-hover',
isCurrentWorkspace(workspace) && 'bg-secondary-background'
)
"
@click="handleSelectWorkspace(workspace)"
>
<WorkspaceProfilePic
class="size-8 text-sm"
:workspace-name="workspace.name"
/>
<div class="flex min-w-0 flex-1 flex-col items-start gap-1">
<span class="text-sm text-base-foreground">
{{ workspace.name }}
</span>
<span
v-if="workspace.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>
<!-- 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 ?? 'personal'"
>
<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" /> -->
@@ -82,25 +113,51 @@
</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 type { AvailableWorkspace } from '@/platform/workspace/composables/useWorkspace'
import { useWorkspace } from '@/platform/workspace/composables/useWorkspace'
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
import type {
WorkspaceRole,
WorkspaceType
} from '@/platform/workspace/api/workspaceApi'
import { useWorkspaceStore } from '@/platform/workspace/stores/workspaceStore'
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 {
workspaceId,
availableWorkspaces,
canCreateWorkspace,
switchWorkspace
} = useWorkspace()
const { switchWithConfirmation } = useWorkspaceSwitch()
const { showDeleteWorkspaceDialog } = useDialogService()
const workspaceStore = useWorkspaceStore()
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
@@ -112,12 +169,32 @@ function getRoleLabel(role: AvailableWorkspace['role']): string {
return ''
}
function handleSelectWorkspace(workspace: AvailableWorkspace) {
switchWorkspace(workspace)
emit('select', workspace)
async function handleSelectWorkspace(workspace: AvailableWorkspace) {
if (!workspace.id) {
// Personal workspace doesn't have an ID in this context
return
}
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) {
if (!workspace.id) return
showDeleteWorkspaceDialog({
workspaceId: workspace.id,
workspaceName: workspace.name
})
emit('delete', workspace)
}
</script>

View File

@@ -2147,7 +2147,8 @@
},
"deleteDialog": {
"title": "Delete this workspace?",
"message": "Any unused credits or unsaved assets will be lost. This action cannot be undone."
"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?",
@@ -2183,7 +2184,24 @@
"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": {
@@ -2710,7 +2728,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": {

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

@@ -4,19 +4,19 @@ import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch
import type { WorkspaceWithRole } from '@/platform/auth/workspace/workspaceTypes'
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/workspaceStore', () => ({
useWorkspaceStore: () => ({
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 { useWorkspaceStore } from '@/platform/workspace/stores/workspaceStore'
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 = useWorkspaceStore()
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

@@ -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

@@ -16,7 +16,7 @@
<Button
variant="primary"
class="ml-auto rounded-lg px-4 py-2 text-sm font-normal"
@click="showSubscriptionDialog"
@click="handleSubscribeWorkspace"
>
{{ $t('subscription.subscribeNow') }}
</Button>
@@ -236,6 +236,7 @@
</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'
@@ -255,11 +256,15 @@ import {
getTierFeatures,
getTierPrice
} from '@/platform/cloud/subscription/constants/tierPricing'
import { useWorkspace } from '@/platform/workspace/composables/useWorkspace'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useWorkspaceStore } from '@/platform/workspace/stores/workspaceStore'
import { cn } from '@/utils/tailwindUtil'
const authActions = useFirebaseAuthActions()
const { permissions, isWorkspaceSubscribed, workspaceRole } = useWorkspace()
const workspaceStore = useWorkspaceStore()
const { isWorkspaceSubscribed } = storeToRefs(workspaceStore)
const { subscribeWorkspace } = workspaceStore
const { permissions, workspaceRole } = useWorkspaceUI()
const { t, n } = useI18n()
// OWNER with unsubscribed workspace
@@ -267,6 +272,11 @@ const isOwnerUnsubscribed = computed(
() => workspaceRole.value === 'owner' && !isWorkspaceSubscribed.value
)
// Demo: Subscribe workspace to PRO monthly plan
function handleSubscribeWorkspace() {
subscribeWorkspace('PRO_MONTHLY')
}
const {
isActiveSubscription,
isCancelled,

View File

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

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

@@ -1,12 +1,14 @@
import type { AxiosResponse } from 'axios'
import axios from 'axios'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
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 (matching useWorkspaceAuth types)
import { sessionManager } from '../services/sessionManager'
// Types aligned with backend API
export type WorkspaceType = 'personal' | 'team'
export type WorkspaceRole = 'owner' | 'member'
@@ -20,29 +22,50 @@ export interface WorkspaceWithRole extends Workspace {
role: WorkspaceRole
}
export interface WorkspaceMember {
// Member type from API
export interface Member {
id: string
name: string
email: string
role: WorkspaceRole
joined_at: string
}
export interface PaginationInfo {
offset: number
limit: number
total: number
}
export 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
invite_link: string
}
export interface WorkspaceDetails extends WorkspaceWithRole {
members: WorkspaceMember[]
pending_invites: PendingInvite[]
subscription_status: {
is_active: boolean
plan: string | null
}
export interface ListInvitesResponse {
invites: PendingInvite[]
}
export interface CreateInviteRequest {
email: string
}
export interface AcceptInviteResponse {
workspace_id: string
workspace_name: string
}
export interface CreateWorkspacePayload {
@@ -53,16 +76,6 @@ export interface UpdateWorkspacePayload {
name: string
}
export interface CreateInvitePayload {
email: string
}
export interface CreateInviteResponse {
id: string
invite_link: string
expires_at: string
}
// API responses
export interface ListWorkspacesResponse {
workspaces: WorkspaceWithRole[]
@@ -80,12 +93,13 @@ export class WorkspaceApiError extends Error {
}
const workspaceApiClient = axios.create({
baseURL: getComfyApiBaseUrl(),
headers: {
'Content-Type': 'application/json'
}
})
type RequestHeaders = AuthHeader & { 'X-Workspace-ID'?: string }
async function withAuth<T>(
request: (headers: AuthHeader) => Promise<AxiosResponse<T>>
): Promise<T> {
@@ -110,21 +124,57 @@ async function withAuth<T>(
}
}
/**
* Wrapper that adds both auth header and workspace ID header.
* Use for workspace-scoped endpoints (e.g., /api/workspace/members).
*/
async function withWorkspaceAuth<T>(
request: (headers: RequestHeaders) => Promise<AxiosResponse<T>>
): Promise<T> {
const authHeader = await useFirebaseAuthStore().getAuthHeader()
if (!authHeader) {
throw new WorkspaceApiError(
t('toastMessages.userNotAuthenticated'),
401,
'NOT_AUTHENTICATED'
)
}
const workspaceId = sessionManager.getCurrentWorkspaceId()
if (!workspaceId) {
throw new WorkspaceApiError(
'No active workspace',
400,
'NO_ACTIVE_WORKSPACE'
)
}
const headers: RequestHeaders = {
...authHeader,
'X-Workspace-ID': workspaceId
}
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
throw new WorkspaceApiError(message, status)
}
throw err
}
}
export const workspaceApi = {
/**
* List all workspaces the user has access to
* GET /api/workspaces
*/
list: (): Promise<ListWorkspacesResponse> =>
withAuth((headers) => workspaceApiClient.get('/workspaces', { headers })),
/**
* Get workspace details including members and invites
* GET /api/workspaces/:id
*/
get: (workspaceId: string): Promise<WorkspaceDetails> =>
withAuth((headers) =>
workspaceApiClient.get(`/workspaces/${workspaceId}`, { headers })
workspaceApiClient.get(api.apiURL('/workspaces'), { headers })
),
/**
@@ -133,7 +183,7 @@ export const workspaceApi = {
*/
create: (payload: CreateWorkspacePayload): Promise<WorkspaceWithRole> =>
withAuth((headers) =>
workspaceApiClient.post('/workspaces', payload, { headers })
workspaceApiClient.post(api.apiURL('/workspaces'), payload, { headers })
),
/**
@@ -145,9 +195,11 @@ export const workspaceApi = {
payload: UpdateWorkspacePayload
): Promise<WorkspaceWithRole> =>
withAuth((headers) =>
workspaceApiClient.patch(`/workspaces/${workspaceId}`, payload, {
headers
})
workspaceApiClient.patch(
api.apiURL(`/workspaces/${workspaceId}`),
payload,
{ headers }
)
),
/**
@@ -156,55 +208,82 @@ export const workspaceApi = {
*/
delete: (workspaceId: string): Promise<void> =>
withAuth((headers) =>
workspaceApiClient.delete(`/workspaces/${workspaceId}`, { headers })
),
/**
* Leave a workspace (member only)
* POST /api/workspaces/:id/leave
*/
leave: (workspaceId: string): Promise<void> =>
withAuth((headers) =>
workspaceApiClient.post(`/workspaces/${workspaceId}/leave`, null, {
workspaceApiClient.delete(api.apiURL(`/workspaces/${workspaceId}`), {
headers
})
),
/**
* Create an invite link for a workspace
* POST /api/workspaces/:id/invites
* Leave the current workspace.
* POST /api/workspace/leave
*/
createInvite: (
workspaceId: string,
payload: CreateInvitePayload
): Promise<CreateInviteResponse> =>
withAuth((headers) =>
workspaceApiClient.post(`/workspaces/${workspaceId}/invites`, payload, {
leave: (): Promise<void> =>
withWorkspaceAuth((headers) =>
workspaceApiClient.post(api.apiURL('/workspace/leave'), null, { headers })
),
/**
* List workspace members (paginated).
* GET /api/workspace/members
*/
listMembers: (params?: ListMembersParams): Promise<ListMembersResponse> =>
withWorkspaceAuth((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> =>
withWorkspaceAuth((headers) =>
workspaceApiClient.delete(api.apiURL(`/workspace/members/${userId}`), {
headers
})
),
/**
* Revoke a pending invite
* DELETE /api/workspaces/:id/invites/:inviteId
* List pending invites for the workspace.
* GET /api/workspace/invites
*/
revokeInvite: (workspaceId: string, inviteId: string): Promise<void> =>
withAuth((headers) =>
workspaceApiClient.delete(
`/workspaces/${workspaceId}/invites/${inviteId}`,
{ headers }
)
listInvites: (): Promise<ListInvitesResponse> =>
withWorkspaceAuth((headers) =>
workspaceApiClient.get(api.apiURL('/workspace/invites'), { headers })
),
/**
* Remove a member from workspace
* DELETE /api/workspaces/:id/members/:memberId
* Create an invite for the workspace.
* POST /api/workspace/invites
*/
removeMember: (workspaceId: string, memberId: string): Promise<void> =>
createInvite: (payload: CreateInviteRequest): Promise<PendingInvite> =>
withWorkspaceAuth((headers) =>
workspaceApiClient.post(api.apiURL('/workspace/invites'), payload, {
headers
})
),
/**
* Revoke a pending invite.
* DELETE /api/workspace/invites/:inviteId
*/
revokeInvite: (inviteId: string): Promise<void> =>
withWorkspaceAuth((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.delete(
`/workspaces/${workspaceId}/members/${memberId}`,
{ headers }
)
workspaceApiClient.post(api.apiURL(`/invites/${token}/accept`), null, {
headers
})
)
}

View File

@@ -0,0 +1,235 @@
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
*/
const preservedQueryMocks = vi.hoisted(() => ({
clearPreservedQuery: vi.fn()
}))
vi.mock(
'@/platform/navigation/preservedQueryManager',
() => preservedQueryMocks
)
// Mock toast store
const mockToastAdd = vi.fn()
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({
add: mockToastAdd
})
}))
// Mock i18n
vi.mock('vue-i18n', () => ({
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.error') return 'Error'
return key
})
})
}))
describe('useInviteUrlLoader', () => {
const mockReplaceState = vi.fn()
const mockLocation = {
search: '',
href: 'https://cloud.comfy.org/',
origin: 'https://cloud.comfy.org'
}
beforeEach(() => {
vi.clearAllMocks()
mockLocation.search = ''
mockLocation.href = 'https://cloud.comfy.org/'
// Mock location using vi.stubGlobal
vi.stubGlobal('location', mockLocation)
// Mock history.replaceState
vi.spyOn(window.history, 'replaceState').mockImplementation(
mockReplaceState
)
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('getInviteTokenFromUrl', () => {
it('returns null when no invite param present', () => {
window.location.search = ''
const { getInviteTokenFromUrl } = useInviteUrlLoader()
const token = getInviteTokenFromUrl()
expect(token).toBeNull()
})
it('returns token when invite param is present', () => {
window.location.search = '?invite=test-token-123'
const { getInviteTokenFromUrl } = useInviteUrlLoader()
const token = getInviteTokenFromUrl()
expect(token).toBe('test-token-123')
})
it('returns null for empty invite param', () => {
window.location.search = '?invite='
const { getInviteTokenFromUrl } = useInviteUrlLoader()
const token = getInviteTokenFromUrl()
expect(token).toBeNull()
})
it('returns null for whitespace-only invite param', () => {
window.location.search = '?invite=%20%20'
const { getInviteTokenFromUrl } = useInviteUrlLoader()
const token = getInviteTokenFromUrl()
expect(token).toBeNull()
})
})
describe('clearInviteTokenFromUrl', () => {
it('removes invite param from URL', () => {
window.location.search = '?invite=test-token'
window.location.href = 'https://cloud.comfy.org/?invite=test-token'
const { clearInviteTokenFromUrl } = useInviteUrlLoader()
clearInviteTokenFromUrl()
expect(mockReplaceState).toHaveBeenCalledWith(
window.history.state,
'',
'https://cloud.comfy.org/'
)
})
it('preserves other query params when removing invite', () => {
window.location.search = '?invite=test-token&other=param'
window.location.href =
'https://cloud.comfy.org/?invite=test-token&other=param'
const { clearInviteTokenFromUrl } = useInviteUrlLoader()
clearInviteTokenFromUrl()
expect(mockReplaceState).toHaveBeenCalledWith(
window.history.state,
'',
'https://cloud.comfy.org/?other=param'
)
})
})
describe('loadInviteFromUrl', () => {
it('does nothing when no invite param present', async () => {
window.location.search = ''
const mockAcceptInvite = vi.fn()
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl(mockAcceptInvite)
expect(mockAcceptInvite).not.toHaveBeenCalled()
expect(mockToastAdd).not.toHaveBeenCalled()
expect(mockReplaceState).not.toHaveBeenCalled()
})
it('accepts invite and shows success toast on success', async () => {
window.location.search = '?invite=valid-token'
window.location.href = 'https://cloud.comfy.org/?invite=valid-token'
const mockAcceptInvite = vi.fn().mockResolvedValue({
workspaceId: 'ws-123',
workspaceName: 'Test Workspace'
})
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl(mockAcceptInvite)
expect(mockAcceptInvite).toHaveBeenCalledWith('valid-token')
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'success',
summary: 'Invite Accepted',
detail: 'You have been added to Test Workspace',
life: 5000
})
expect(mockReplaceState).toHaveBeenCalled()
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
'invite'
)
})
it('shows error toast when invite acceptance fails', async () => {
window.location.search = '?invite=invalid-token'
window.location.href = 'https://cloud.comfy.org/?invite=invalid-token'
const mockAcceptInvite = vi
.fn()
.mockRejectedValue(new Error('Invalid invite'))
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl(mockAcceptInvite)
expect(mockAcceptInvite).toHaveBeenCalledWith('invalid-token')
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'Failed to Accept Invite',
detail: 'Error',
life: 5000
})
})
it('cleans up URL even on error', async () => {
window.location.search = '?invite=invalid-token'
window.location.href = 'https://cloud.comfy.org/?invite=invalid-token'
const mockAcceptInvite = vi
.fn()
.mockRejectedValue(new Error('Invalid invite'))
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl(mockAcceptInvite)
expect(mockReplaceState).toHaveBeenCalled()
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
'invite'
)
})
it('clears preserved query on success', async () => {
window.location.search = '?invite=valid-token'
window.location.href = 'https://cloud.comfy.org/?invite=valid-token'
const mockAcceptInvite = vi.fn().mockResolvedValue({
workspaceId: 'ws-123',
workspaceName: 'Test Workspace'
})
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl(mockAcceptInvite)
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
'invite'
)
})
})
})

View File

@@ -0,0 +1,84 @@
import { useI18n } from 'vue-i18n'
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useToastStore } from '@/platform/updates/common/toastStore'
type AcceptInviteFn = (
token: string
) => Promise<{ workspaceId: string; workspaceName: string }>
/**
* Composable for loading workspace invites from URL query parameters
*
* Supports URLs like:
* - /?invite=TOKEN (accepts workspace invite)
*
* Input validation:
* - Token parameter must be a non-empty string
*/
export function useInviteUrlLoader() {
const { t } = useI18n()
const toastStore = useToastStore()
const INVITE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.INVITE
/**
* Gets the invite token from URL query parameters
*/
function getInviteTokenFromUrl(): string | null {
const params = new URLSearchParams(window.location.search)
const token = params.get('invite')
return token && token.trim().length > 0 ? token : null
}
/**
* Removes the invite parameter from URL without triggering navigation
*/
function clearInviteTokenFromUrl() {
const url = new URL(window.location.href)
url.searchParams.delete('invite')
window.history.replaceState(window.history.state, '', url.toString())
}
/**
* Loads and accepts workspace invite from URL query parameters if present
* Handles errors internally and shows appropriate user feedback
*/
async function loadInviteFromUrl(acceptInvite: AcceptInviteFn) {
const token = getInviteTokenFromUrl()
if (!token) {
return
}
try {
const result = await acceptInvite(token)
toastStore.add({
severity: 'success',
summary: t('workspace.inviteAccepted'),
detail: t('workspace.addedToWorkspace', {
workspaceName: result.workspaceName
}),
life: 5000
})
} catch (error) {
console.error('[useInviteUrlLoader] Failed to accept invite:', error)
toastStore.add({
severity: 'error',
summary: t('workspace.inviteFailed'),
detail: t('g.error'),
life: 5000
})
} finally {
clearInviteTokenFromUrl()
clearPreservedQuery(INVITE_NAMESPACE)
}
}
return {
getInviteTokenFromUrl,
clearInviteTokenFromUrl,
loadInviteFromUrl
}
}

View File

@@ -1,605 +0,0 @@
import { computed, ref, shallowRef } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import type {
WorkspaceRole,
WorkspaceType,
WorkspaceWithRole
} from '../api/workspaceApi'
// Re-export API types for consumers
export type { WorkspaceRole, WorkspaceType, WorkspaceWithRole }
// Extended member type for UI (adds joinDate as Date)
export interface WorkspaceMember {
id: string
name: string
email: string
role?: WorkspaceRole
joinDate: Date
}
// Extended invite type for UI (adds dates as Date objects)
export interface PendingInvite {
id: string
name: string
email: string
inviteDate: Date
expiryDate: Date
inviteLink: string
}
type SubscriptionPlan = 'PRO_MONTHLY' | 'PRO_YEARLY' | null
interface WorkspaceState extends WorkspaceWithRole {
isSubscribed: boolean
subscriptionPlan: SubscriptionPlan
members: WorkspaceMember[]
pendingInvites: PendingInvite[]
}
export interface AvailableWorkspace {
id: string | null
name: string
type: WorkspaceType
role: WorkspaceRole
}
/** 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
}
// Role-based permissions mapping
// Note: 'personal' type workspaces have owner role with restricted permissions
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
}
}
const MAX_OWNED_WORKSPACES = 10
const MAX_WORKSPACE_MEMBERS = 50
function generateId(): string {
return Math.random().toString(36).substring(2, 10)
}
function createPersonalWorkspace(): WorkspaceState {
return {
id: 'personal',
name: 'Personal workspace',
type: 'personal',
role: 'owner',
isSubscribed: true,
subscriptionPlan: null,
members: [],
pendingInvites: []
}
}
// =============================================================================
// MODULE-LEVEL STATE
// Persists across component lifecycle - not disposed when components unmount
// =============================================================================
const _workspaces = shallowRef<WorkspaceState[]>([createPersonalWorkspace()])
const _currentWorkspaceIndex = ref(0)
const _activeTab = ref<string>('plan')
// Helper to get current workspace
function getCurrentWorkspace(): WorkspaceState {
return _workspaces.value[_currentWorkspaceIndex.value]
}
// Helper to update current workspace immutably
function updateCurrentWorkspace(updates: Partial<WorkspaceState>) {
const index = _currentWorkspaceIndex.value
const updated = { ..._workspaces.value[index], ...updates }
_workspaces.value = [
..._workspaces.value.slice(0, index),
updated,
..._workspaces.value.slice(index + 1)
]
}
/**
* Internal composable implementation for workspace management.
* Uses module-level state to persist across component lifecycle.
* Will integrate with useWorkspaceAuth once that PR lands.
*/
function useWorkspaceInternal() {
const { userDisplayName, userEmail } = useCurrentUser()
// Computed properties derived from module-level state
const currentWorkspace = computed(() => getCurrentWorkspace())
const workspaceId = computed(() => currentWorkspace.value?.id ?? null)
const workspaceName = computed(
() => currentWorkspace.value?.name ?? 'Personal workspace'
)
const workspaceType = computed(
() => currentWorkspace.value?.type ?? 'personal'
)
const workspaceRole = computed(() => currentWorkspace.value?.role ?? 'owner')
const activeTab = computed(() => _activeTab.value)
const isPersonalWorkspace = computed(
() => currentWorkspace.value?.type === 'personal'
)
const isWorkspaceSubscribed = computed(
() => currentWorkspace.value?.isSubscribed ?? false
)
const subscriptionPlan = computed(
() => currentWorkspace.value?.subscriptionPlan ?? null
)
const permissions = computed<WorkspacePermissions>(() =>
getPermissions(workspaceType.value, workspaceRole.value)
)
const uiConfig = computed<WorkspaceUIConfig>(() =>
getUIConfig(workspaceType.value, workspaceRole.value)
)
// For personal workspace, always show current user as the only member
const members = computed<WorkspaceMember[]>(() => {
if (isPersonalWorkspace.value) {
return [
{
id: 'current-user',
name: userDisplayName.value ?? 'You',
email: userEmail.value ?? '',
role: 'owner',
joinDate: new Date()
}
]
}
return currentWorkspace.value?.members ?? []
})
const pendingInvites = computed(
() => currentWorkspace.value?.pendingInvites ?? []
)
const totalMemberSlots = computed(
() => members.value.length + pendingInvites.value.length
)
const isInviteLimitReached = computed(
() => totalMemberSlots.value >= MAX_WORKSPACE_MEMBERS
)
const availableWorkspaces = computed<AvailableWorkspace[]>(() =>
_workspaces.value.map((w) => ({
id: w.id,
name: w.name,
type: w.type,
role: w.role
}))
)
const ownedWorkspacesCount = computed(
() => _workspaces.value.filter((w) => w.role === 'owner').length
)
const canCreateWorkspace = computed(
() => ownedWorkspacesCount.value < MAX_OWNED_WORKSPACES
)
// Tab management
function setActiveTab(tab: string | number) {
_activeTab.value = String(tab)
}
/**
* Switch to a different workspace by ID.
* TODO: Integrate with useWorkspaceAuth.switchWorkspace() when PR lands
*/
function switchWorkspace(workspace: AvailableWorkspace) {
const index = _workspaces.value.findIndex((w) => w.id === workspace.id)
if (index !== -1) {
_currentWorkspaceIndex.value = index
}
}
/**
* Create a new workspace.
* TODO: Replace with workspaceApi.create() call
*/
function createWorkspace(name: string): WorkspaceState {
const newWorkspace: WorkspaceState = {
id: `workspace-${generateId()}`,
name,
type: 'team',
role: 'owner',
isSubscribed: false,
subscriptionPlan: null,
members: [],
pendingInvites: [
// Stub invite for testing
{
id: generateId(),
name: 'PendingUser',
email: 'pending@example.com',
inviteDate: new Date(),
expiryDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
inviteLink: `https://cloud.comfy.org/workspace/invite/${generateId()}`
}
]
}
_workspaces.value = [..._workspaces.value, newWorkspace]
_currentWorkspaceIndex.value = _workspaces.value.length - 1
return newWorkspace
}
/**
* Subscribe the current workspace to a plan.
* TODO: Replace with subscription API call
*/
function subscribeWorkspace(plan: SubscriptionPlan = 'PRO_MONTHLY') {
updateCurrentWorkspace({
isSubscribed: true,
subscriptionPlan: plan
})
}
/**
* Delete the current workspace (owner only).
* TODO: Replace with workspaceApi.delete() call
*/
function deleteWorkspace() {
const current = getCurrentWorkspace()
if (current.role === 'owner' && current.type === 'team') {
_workspaces.value = _workspaces.value.filter((w) => w.id !== current.id)
_currentWorkspaceIndex.value = 0
}
}
/**
* Leave the current workspace (member only).
* TODO: Replace with workspaceApi.leave() call
*/
function leaveWorkspace() {
const current = getCurrentWorkspace()
if (current.role === 'member') {
_workspaces.value = _workspaces.value.filter((w) => w.id !== current.id)
_currentWorkspaceIndex.value = 0
}
}
/**
* Update workspace name.
* TODO: Replace with workspaceApi.update() call
*/
function updateWorkspaceName(name: string) {
updateCurrentWorkspace({ name })
}
/**
* Add a member to the current workspace.
* TODO: This happens via invite acceptance on backend
*/
function addMember(member: Omit<WorkspaceMember, 'id' | 'joinDate'>) {
const current = getCurrentWorkspace()
const newMember: WorkspaceMember = {
...member,
id: generateId(),
joinDate: new Date()
}
updateCurrentWorkspace({
members: [...current.members, newMember]
})
}
/**
* Remove a member from the current workspace.
* TODO: Replace with workspaceApi.removeMember() call
*/
function removeMember(memberId: string) {
const current = getCurrentWorkspace()
updateCurrentWorkspace({
members: current.members.filter((m) => m.id !== memberId)
})
}
/**
* Revoke a pending invite.
* TODO: Replace with workspaceApi.revokeInvite() call
*/
function revokeInvite(inviteId: string) {
const current = getCurrentWorkspace()
updateCurrentWorkspace({
pendingInvites: current.pendingInvites.filter((i) => i.id !== inviteId)
})
}
/**
* Accept a pending invite (for demo/testing).
*/
function acceptInvite(inviteId: string) {
const current = getCurrentWorkspace()
const invite = current.pendingInvites.find((i) => i.id === inviteId)
if (invite) {
const updatedPending = current.pendingInvites.filter(
(i) => i.id !== inviteId
)
const newMember: WorkspaceMember = {
id: generateId(),
name: invite.name,
email: invite.email,
role: 'member',
joinDate: new Date()
}
updateCurrentWorkspace({
pendingInvites: updatedPending,
members: [...current.members, newMember]
})
}
}
// Async API methods (stubs for now)
async function fetchMembers(): Promise<WorkspaceMember[]> {
// TODO: Replace with workspaceApi.get() call
return members.value
}
async function fetchPendingInvites(): Promise<PendingInvite[]> {
// TODO: Replace with workspaceApi.get() call
return pendingInvites.value
}
async function copyInviteLink(inviteId: string): Promise<string> {
const invite = pendingInvites.value.find((i) => i.id === inviteId)
if (invite) {
await navigator.clipboard.writeText(invite.inviteLink)
return invite.inviteLink
}
throw new Error('Invite not found')
}
/**
* Copy invite link and simulate member accepting (for demo).
*/
async function copyInviteLinkAndAccept(inviteId: string): Promise<string> {
const invite = pendingInvites.value.find((i) => i.id === inviteId)
if (invite) {
await navigator.clipboard.writeText(invite.inviteLink)
revokeInvite(inviteId)
addMember({
name: invite.name,
email: invite.email,
role: 'member'
})
return invite.inviteLink
}
throw new Error('Invite not found')
}
/**
* Create an invite link for a given email.
* TODO: Replace with workspaceApi.createInvite() call
*/
async function createInviteLink(email: string): Promise<string> {
await new Promise((resolve) => setTimeout(resolve, 500))
const inviteId = generateId()
const inviteLink = `https://cloud.comfy.org/workspace/invite/${inviteId}`
const current = getCurrentWorkspace()
const newInvite: PendingInvite = {
id: inviteId,
name: email.split('@')[0],
email,
inviteDate: new Date(),
expiryDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
inviteLink
}
updateCurrentWorkspace({
pendingInvites: [...current.pendingInvites, newInvite]
})
return inviteLink
}
// Dev helpers for testing UI states
function setMockRole(role: WorkspaceRole) {
updateCurrentWorkspace({ role })
}
function setMockSubscribed(subscribed: boolean) {
updateCurrentWorkspace({ isSubscribed: subscribed })
}
function setMockType(type: WorkspaceType) {
updateCurrentWorkspace({ type })
}
// Expose to window for dev testing
if (typeof window !== 'undefined') {
const w = window as Window & {
__setWorkspaceRole?: typeof setMockRole
__setWorkspaceSubscribed?: typeof setMockSubscribed
__setWorkspaceType?: typeof setMockType
}
w.__setWorkspaceRole = setMockRole
w.__setWorkspaceSubscribed = setMockSubscribed
w.__setWorkspaceType = setMockType
}
return {
// Current workspace state
workspaceId,
workspaceName,
workspaceType,
workspaceRole,
activeTab,
isPersonalWorkspace,
isWorkspaceSubscribed,
subscriptionPlan,
permissions,
uiConfig,
// Tab management
setActiveTab,
// Workspace switching/management
availableWorkspaces,
ownedWorkspacesCount,
canCreateWorkspace,
switchWorkspace,
createWorkspace,
subscribeWorkspace,
deleteWorkspace,
leaveWorkspace,
updateWorkspaceName,
// Members
members,
pendingInvites,
totalMemberSlots,
isInviteLimitReached,
fetchMembers,
fetchPendingInvites,
revokeInvite,
acceptInvite,
copyInviteLink,
copyInviteLinkAndAccept,
createInviteLink,
addMember,
removeMember,
// Dev helpers
setMockRole,
setMockSubscribed,
setMockType
}
}
/**
* Shared composable for workspace management.
* Uses module-level state to persist across component lifecycle.
* The createSharedComposable wrapper ensures computed properties
* are shared efficiently across components.
*
* Future integration:
* - Will consume useWorkspaceAuth for authentication context
* - Will use workspaceApi for backend calls
*/
export const useWorkspace = createSharedComposable(useWorkspaceInternal)

View File

@@ -0,0 +1,177 @@
import { computed, ref } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import type { WorkspaceRole, WorkspaceType } from '../api/workspaceApi'
import { useWorkspaceStore } from '../stores/workspaceStore'
/** Permission flags for workspace actions */
export interface WorkspacePermissions {
canViewOtherMembers: boolean
canViewPendingInvites: boolean
canInviteMembers: boolean
canManageInvites: boolean
canRemoveMembers: boolean
canLeaveWorkspace: boolean
canAccessWorkspaceMenu: boolean
canManageSubscription: boolean
}
/** UI configuration for workspace role */
export interface WorkspaceUIConfig {
showMembersList: boolean
showPendingTab: boolean
showSearch: boolean
showDateColumn: boolean
showRoleBadge: boolean
membersGridCols: string
pendingGridCols: string
headerGridCols: string
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 = useWorkspaceStore()
// 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,98 @@
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')
}
},
/**
* Switch workspace and reload the page.
* Code after calling this won't execute (page is gone).
*/
switchWorkspaceAndReload(workspaceId: string): void {
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.clearCurrentWorkspaceId()
window.location.reload()
}
}

View File

@@ -0,0 +1,619 @@
import { defineStore } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { sessionManager } from '../services/sessionManager'
import type {
ListMembersParams,
Member,
PendingInvite as ApiPendingInvite,
WorkspaceWithRole
} from '../api/workspaceApi'
import { workspaceApi } 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[]
}
export 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 useWorkspaceStore = 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)
// ════════════════════════════════════════════════════════════
// 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)
}
// ════════════════════════════════════════════════════════════
// 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)
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
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') {
updateActiveWorkspace({
isSubscribed: true,
subscriptionPlan: plan
})
}
// ════════════════════════════════════════════════════════════
// DEV HELPERS
// ════════════════════════════════════════════════════════════
function setMockRole(role: 'owner' | 'member') {
updateActiveWorkspace({ role })
}
function setMockSubscribed(subscribed: boolean) {
updateActiveWorkspace({ isSubscribed: subscribed })
}
function setMockType(type: 'personal' | 'team') {
updateActiveWorkspace({ type })
}
// ════════════════════════════════════════════════════════════
// 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
initialize,
refreshWorkspaces,
// Workspace Actions
switchWorkspace,
createWorkspace,
deleteWorkspace,
renameWorkspace,
updateWorkspaceName,
leaveWorkspace,
// Member Actions
fetchMembers,
removeMember,
// Invite Actions
fetchPendingInvites,
createInvite,
revokeInvite,
acceptInvite,
getInviteLink,
createInviteLink,
copyInviteLink,
// Subscription
subscribeWorkspace,
// Dev helpers
setMockRole,
setMockSubscribed,
setMockType
}
})

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,32 @@ 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
// and to handle invite URLs early in the lifecycle
// TODO: Use flags.teamWorkspacesEnabled when backend enables the flag
const teamWorkspacesEnabled = true
if (to.path === '/' && teamWorkspacesEnabled) {
const { useWorkspaceStore } =
await import('@/platform/workspace/stores/workspaceStore')
const workspaceStore = useWorkspaceStore()
if (workspaceStore.initState === 'uninitialized') {
try {
await workspaceStore.initialize()
// Handle invite URL if present (e.g., ?invite=TOKEN)
const { useInviteUrlLoader } =
await import('@/platform/workspace/composables/useInviteUrlLoader')
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl(workspaceStore.acceptInvite)
} 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

@@ -3,6 +3,7 @@ import type { Component } from 'vue'
import ApiNodesSignInContent from '@/components/dialog/content/ApiNodesSignInContent.vue'
import CreateWorkspaceDialogContent from '@/components/dialog/content/workspace/CreateWorkspaceDialogContent.vue'
import EditWorkspaceDialogContent from '@/components/dialog/content/workspace/EditWorkspaceDialogContent.vue'
import DeleteWorkspaceDialogContent from '@/components/dialog/content/workspace/DeleteWorkspaceDialogContent.vue'
import InviteMemberDialogContent from '@/components/dialog/content/workspace/InviteMemberDialogContent.vue'
import LeaveWorkspaceDialogContent from '@/components/dialog/content/workspace/LeaveWorkspaceDialogContent.vue'
@@ -526,11 +527,10 @@ export const useDialogService = () => {
show()
}
function showLeaveWorkspaceDialog(onConfirm: () => void | Promise<void>) {
function showLeaveWorkspaceDialog() {
return dialogStore.showDialog({
key: 'leave-workspace',
component: LeaveWorkspaceDialogContent,
props: { onConfirm },
dialogComponentProps: {
headless: true,
pt: {
@@ -542,11 +542,14 @@ export const useDialogService = () => {
})
}
function showDeleteWorkspaceDialog(onConfirm: () => void | Promise<void>) {
function showDeleteWorkspaceDialog(options?: {
workspaceId?: string
workspaceName?: string
}) {
return dialogStore.showDialog({
key: 'delete-workspace',
component: DeleteWorkspaceDialogContent,
props: { onConfirm },
props: options,
dialogComponentProps: {
headless: true,
pt: {
@@ -558,11 +561,11 @@ export const useDialogService = () => {
})
}
function showRemoveMemberDialog(onConfirm: () => void | Promise<void>) {
function showRemoveMemberDialog(memberId: string) {
return dialogStore.showDialog({
key: 'remove-member',
component: RemoveMemberDialogContent,
props: { onConfirm },
props: { memberId },
dialogComponentProps: {
headless: true,
pt: {
@@ -574,11 +577,11 @@ export const useDialogService = () => {
})
}
function showRevokeInviteDialog(onConfirm: () => void | Promise<void>) {
function showRevokeInviteDialog(inviteId: string) {
return dialogStore.showDialog({
key: 'revoke-invite',
component: RevokeInviteDialogContent,
props: { onConfirm },
props: { inviteId },
dialogComponentProps: {
headless: true,
pt: {
@@ -626,6 +629,21 @@ export const useDialogService = () => {
})
}
function showEditWorkspaceDialog() {
return dialogStore.showDialog({
key: 'edit-workspace',
component: EditWorkspaceDialogContent,
dialogComponentProps: {
headless: true,
pt: {
header: { class: 'p-0! hidden' },
content: { class: 'p-0! m-0! rounded-2xl' },
root: { class: 'rounded-2xl max-w-[400px] w-full' }
}
}
})
}
return {
showLoadWorkflowWarning,
showMissingModelsWarning,
@@ -643,6 +661,7 @@ export const useDialogService = () => {
showRevokeInviteDialog,
showInviteMemberDialog,
showCreateWorkspaceDialog,
showEditWorkspaceDialog,
showExtensionDialog,
prompt,
showErrorDialog,

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
}
})