mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-10 10:00:08 +00:00
feat: workspace switcher and misc
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export const PRESERVED_QUERY_NAMESPACES = {
|
||||
TEMPLATE: 'template'
|
||||
TEMPLATE: 'template',
|
||||
INVITE: 'invite'
|
||||
} as const
|
||||
|
||||
753
src/platform/workspace/WORKSPACE_IMPLEMENTATION_SPEC.md
Normal file
753
src/platform/workspace/WORKSPACE_IMPLEMENTATION_SPEC.md
Normal 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
|
||||
@@ -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
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
235
src/platform/workspace/composables/useInviteUrlLoader.test.ts
Normal file
235
src/platform/workspace/composables/useInviteUrlLoader.test.ts
Normal 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
84
src/platform/workspace/composables/useInviteUrlLoader.ts
Normal file
84
src/platform/workspace/composables/useInviteUrlLoader.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
177
src/platform/workspace/composables/useWorkspaceUI.ts
Normal file
177
src/platform/workspace/composables/useWorkspaceUI.ts
Normal 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)
|
||||
98
src/platform/workspace/services/sessionManager.ts
Normal file
98
src/platform/workspace/services/sessionManager.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
619
src/platform/workspace/stores/workspaceStore.ts
Normal file
619
src/platform/workspace/stores/workspaceStore.ts
Normal 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
|
||||
}
|
||||
})
|
||||
@@ -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 === '/') {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user