diff --git a/src/components/dialog/content/setting/MembersPanelContent.vue b/src/components/dialog/content/setting/MembersPanelContent.vue index db5e7c458..fb4d22dc7 100644 --- a/src/components/dialog/content/setting/MembersPanelContent.vue +++ b/src/components/dialog/content/setting/MembersPanelContent.vue @@ -234,7 +234,12 @@
{{ invite.name.charAt(0).toUpperCase() }} @@ -338,8 +343,8 @@ const { pendingInvites, fetchMembers, fetchPendingInvites, - copyInviteLink, - revokeInvite, + copyInviteLinkAndAccept, + acceptInvite, isPersonalWorkspace, permissions, uiConfig, @@ -446,14 +451,18 @@ function formatDate(date: Date): string { return d(date, { dateStyle: 'medium' }) } -function handleCopyInviteLink(invite: PendingInvite) { - copyInviteLink(invite.id) +async function handleCopyInviteLink(invite: PendingInvite) { + // Demo: copying invite link simulates user accepting, moves to active members + await copyInviteLinkAndAccept(invite.id) +} + +function handleAcceptInvite(invite: PendingInvite) { + // Demo: clicking avatar accepts the invite, moves to active members + acceptInvite(invite.id) } function handleRevokeInvite(invite: PendingInvite) { - showRevokeInviteDialog(() => { - revokeInvite(invite.id) - }) + showRevokeInviteDialog(invite.id) } function handleCreateWorkspace() { @@ -462,9 +471,7 @@ function handleCreateWorkspace() { }) } -function handleRemoveMember(_member: WorkspaceMember) { - showRemoveMemberDialog(() => { - // TODO: Implement actual remove member API call - }) +function handleRemoveMember(member: WorkspaceMember) { + showRemoveMemberDialog(member.id) } diff --git a/src/components/dialog/content/setting/WorkspacePanelContent.vue b/src/components/dialog/content/setting/WorkspacePanelContent.vue index 8375edb69..73f91bc9e 100644 --- a/src/components/dialog/content/setting/WorkspacePanelContent.vue +++ b/src/components/dialog/content/setting/WorkspacePanelContent.vue @@ -53,7 +53,7 @@ : null " :class="[ - 'flex items-center gap-2 px-3 py-2', + 'flex cursor-pointer items-center gap-2 px-3 py-2', item.class, item.disabled ? 'pointer-events-auto' : '' ]" @@ -110,7 +110,8 @@ const { t } = useI18n() const { showLeaveWorkspaceDialog, showDeleteWorkspaceDialog, - showInviteMemberDialog + showInviteMemberDialog, + showEditWorkspaceDialog } = useDialogService() const { isActiveSubscription } = useSubscription() const { @@ -129,15 +130,11 @@ const { const menu = ref | null>(null) function handleLeaveWorkspace() { - showLeaveWorkspaceDialog(() => { - // TODO: Implement actual leave workspace API call - }) + showLeaveWorkspaceDialog() } function handleDeleteWorkspace() { - showDeleteWorkspaceDialog(() => { - // TODO: Implement actual delete workspace API call - }) + showDeleteWorkspaceDialog() } const isDeleteDisabled = computed( @@ -164,31 +161,50 @@ function handleInviteMember() { }) } -const menuItems = computed(() => { - const action = uiConfig.value.workspaceMenuAction - if (!action) return [] +function handleEditWorkspace() { + showEditWorkspaceDialog() +} - if (action === 'delete') { - return [ - { - label: t('workspacePanel.menu.deleteWorkspace'), - icon: 'pi pi-trash', - class: isDeleteDisabled.value - ? 'text-danger/50 cursor-not-allowed' - : 'text-danger', - disabled: isDeleteDisabled.value, - command: isDeleteDisabled.value ? undefined : handleDeleteWorkspace - } - ] +const menuItems = computed(() => { + const items: Array<{ + label: string + icon: string + class?: string + disabled?: boolean + command?: () => void + }> = [] + + // Add "Edit workspace details" for PERSONAL and OWNER + if (uiConfig.value.showEditWorkspaceMenuItem) { + items.push({ + label: t('workspacePanel.menu.editWorkspaceDetails'), + icon: 'pi pi-pencil', + command: handleEditWorkspace + }) } - return [ - { + const action = uiConfig.value.workspaceMenuAction + + // Add role-specific action + if (action === 'delete') { + items.push({ + label: t('workspacePanel.menu.deleteWorkspace'), + icon: 'pi pi-trash', + class: isDeleteDisabled.value + ? 'text-danger/50 cursor-not-allowed' + : 'text-danger', + disabled: isDeleteDisabled.value, + command: isDeleteDisabled.value ? undefined : handleDeleteWorkspace + }) + } else if (action === 'leave') { + items.push({ label: t('workspacePanel.menu.leaveWorkspace'), icon: 'pi pi-sign-out', command: handleLeaveWorkspace - } - ] + }) + } + + return items }) onMounted(() => { diff --git a/src/components/dialog/content/workspace/CreateWorkspaceDialogContent.vue b/src/components/dialog/content/workspace/CreateWorkspaceDialogContent.vue index 0c317c6fe..2e7b457bf 100644 --- a/src/components/dialog/content/workspace/CreateWorkspaceDialogContent.vue +++ b/src/components/dialog/content/workspace/CreateWorkspaceDialogContent.vue @@ -62,14 +62,16 @@ import { useToast } from 'primevue/usetoast' import { computed, ref } from 'vue' import Button from '@/components/ui/button/Button.vue' +import { useWorkspace } from '@/platform/workspace/composables/useWorkspace' import { useDialogStore } from '@/stores/dialogStore' const { onConfirm } = defineProps<{ - onConfirm: (name: string) => void | Promise + onConfirm?: (name: string) => void | Promise }>() const dialogStore = useDialogStore() const toast = useToast() +const { createWorkspace } = useWorkspace() const loading = ref(false) const workspaceName = ref('') @@ -88,8 +90,13 @@ async function onCreate() { if (!isValidName.value) return loading.value = true try { - await onConfirm(workspaceName.value.trim()) + const name = workspaceName.value.trim() + // Create workspace using global state (creates OWNER unsubscribed workspace) + createWorkspace(name) + // Call optional callback if provided + await onConfirm?.(name) dialogStore.closeDialog({ key: 'create-workspace' }) + // Show toast prompting to subscribe toast.add({ group: 'workspace-created' }) diff --git a/src/components/dialog/content/workspace/DeleteWorkspaceDialogContent.vue b/src/components/dialog/content/workspace/DeleteWorkspaceDialogContent.vue index 5a455a953..64341ed27 100644 --- a/src/components/dialog/content/workspace/DeleteWorkspaceDialogContent.vue +++ b/src/components/dialog/content/workspace/DeleteWorkspaceDialogContent.vue @@ -41,13 +41,11 @@ import { ref } from 'vue' import Button from '@/components/ui/button/Button.vue' +import { useWorkspace } from '@/platform/workspace/composables/useWorkspace' import { useDialogStore } from '@/stores/dialogStore' -const { onConfirm } = defineProps<{ - onConfirm: () => void | Promise -}>() - const dialogStore = useDialogStore() +const { deleteWorkspace } = useWorkspace() const loading = ref(false) function onCancel() { @@ -57,7 +55,7 @@ function onCancel() { async function onDelete() { loading.value = true try { - await onConfirm() + await deleteWorkspace() dialogStore.closeDialog({ key: 'delete-workspace' }) } finally { loading.value = false diff --git a/src/components/dialog/content/workspace/EditWorkspaceDialogContent.vue b/src/components/dialog/content/workspace/EditWorkspaceDialogContent.vue new file mode 100644 index 000000000..d981fc13b --- /dev/null +++ b/src/components/dialog/content/workspace/EditWorkspaceDialogContent.vue @@ -0,0 +1,86 @@ + + + diff --git a/src/components/dialog/content/workspace/LeaveWorkspaceDialogContent.vue b/src/components/dialog/content/workspace/LeaveWorkspaceDialogContent.vue index da6703cba..4475fb5c8 100644 --- a/src/components/dialog/content/workspace/LeaveWorkspaceDialogContent.vue +++ b/src/components/dialog/content/workspace/LeaveWorkspaceDialogContent.vue @@ -41,13 +41,11 @@ import { ref } from 'vue' import Button from '@/components/ui/button/Button.vue' +import { useWorkspace } from '@/platform/workspace/composables/useWorkspace' import { useDialogStore } from '@/stores/dialogStore' -const { onConfirm } = defineProps<{ - onConfirm: () => void | Promise -}>() - const dialogStore = useDialogStore() +const { leaveWorkspace } = useWorkspace() const loading = ref(false) function onCancel() { @@ -57,7 +55,7 @@ function onCancel() { async function onLeave() { loading.value = true try { - await onConfirm() + await leaveWorkspace() dialogStore.closeDialog({ key: 'leave-workspace' }) } finally { loading.value = false diff --git a/src/components/dialog/content/workspace/RemoveMemberDialogContent.vue b/src/components/dialog/content/workspace/RemoveMemberDialogContent.vue index 29f444587..4dab4a779 100644 --- a/src/components/dialog/content/workspace/RemoveMemberDialogContent.vue +++ b/src/components/dialog/content/workspace/RemoveMemberDialogContent.vue @@ -41,13 +41,15 @@ import { ref } from 'vue' import Button from '@/components/ui/button/Button.vue' +import { useWorkspace } from '@/platform/workspace/composables/useWorkspace' import { useDialogStore } from '@/stores/dialogStore' -const { onConfirm } = defineProps<{ - onConfirm: () => void | Promise +const { memberId } = defineProps<{ + memberId: string }>() const dialogStore = useDialogStore() +const { removeMember } = useWorkspace() const loading = ref(false) function onCancel() { @@ -57,7 +59,7 @@ function onCancel() { async function onRemove() { loading.value = true try { - await onConfirm() + await removeMember(memberId) dialogStore.closeDialog({ key: 'remove-member' }) } finally { loading.value = false diff --git a/src/components/dialog/content/workspace/RevokeInviteDialogContent.vue b/src/components/dialog/content/workspace/RevokeInviteDialogContent.vue index 76744a76d..58766372e 100644 --- a/src/components/dialog/content/workspace/RevokeInviteDialogContent.vue +++ b/src/components/dialog/content/workspace/RevokeInviteDialogContent.vue @@ -41,13 +41,15 @@ import { ref } from 'vue' import Button from '@/components/ui/button/Button.vue' +import { useWorkspace } from '@/platform/workspace/composables/useWorkspace' import { useDialogStore } from '@/stores/dialogStore' -const { onConfirm } = defineProps<{ - onConfirm: () => void | Promise +const { inviteId } = defineProps<{ + inviteId: string }>() const dialogStore = useDialogStore() +const { revokeInvite } = useWorkspace() const loading = ref(false) function onCancel() { @@ -57,7 +59,7 @@ function onCancel() { async function onRevoke() { loading.value = true try { - await onConfirm() + await revokeInvite(inviteId) dialogStore.closeDialog({ key: 'revoke-invite' }) } finally { loading.value = false diff --git a/src/components/topbar/CurrentUserPopover.vue b/src/components/topbar/CurrentUserPopover.vue index 77ebdd1de..96b2f4e2d 100644 --- a/src/components/topbar/CurrentUserPopover.vue +++ b/src/components/topbar/CurrentUserPopover.vue @@ -43,10 +43,10 @@ workspaceName }}
- {{ subscriptionTierName }} + {{ workspaceTierName }}
{{ $t('workspaceSwitcher.subscribe') }} @@ -112,7 +112,7 @@ size="sm" class="w-full" data-testid="subscribe-button" - @click="handleOpenPlansAndPricing" + @click="handleOpenWorkspaceSettings" > {{ $t('subscription.subscribeNow') }} @@ -246,7 +246,8 @@ const { workspaceName, workspaceRole, isPersonalWorkspace, - isWorkspaceSubscribed + isWorkspaceSubscribed, + subscriptionPlan } = useWorkspace() const workspaceSwitcherPopover = ref | null>(null) @@ -261,14 +262,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 +281,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 diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 4137e5dea..d95bee73c 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -2128,7 +2128,8 @@ "actions": { "copyLink": "Copy invite link", "revokeInvite": "Revoke invite", - "removeMember": "Remove member" + "removeMember": "Remove member", + "acceptInvite": "Click to accept invite (demo)" }, "noInvites": "No pending invites", "noMembers": "No members", @@ -2136,6 +2137,7 @@ "createNewWorkspace": "create a new one." }, "menu": { + "editWorkspaceDetails": "Edit workspace details", "leaveWorkspace": "Leave Workspace", "deleteWorkspace": "Delete Workspace", "deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first" @@ -2178,6 +2180,11 @@ "namePlaceholder": "Enter workspace name", "create": "Create" }, + "editWorkspaceDialog": { + "title": "Edit workspace details", + "nameLabel": "Workspace name", + "save": "Save changes" + }, "toast": { "workspaceCreated": { "title": "Workspace created", diff --git a/src/platform/cloud/subscription/components/SubscriptionPanelContent.vue b/src/platform/cloud/subscription/components/SubscriptionPanelContent.vue index f1fa68c37..b5b8cffbe 100644 --- a/src/platform/cloud/subscription/components/SubscriptionPanelContent.vue +++ b/src/platform/cloud/subscription/components/SubscriptionPanelContent.vue @@ -16,7 +16,7 @@ @@ -259,7 +259,12 @@ import { useWorkspace } from '@/platform/workspace/composables/useWorkspace' import { cn } from '@/utils/tailwindUtil' const authActions = useFirebaseAuthActions() -const { permissions, isWorkspaceSubscribed, workspaceRole } = useWorkspace() +const { + permissions, + isWorkspaceSubscribed, + workspaceRole, + subscribeWorkspace +} = useWorkspace() 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, diff --git a/src/platform/workspace/composables/useWorkspace.ts b/src/platform/workspace/composables/useWorkspace.ts index fcb81d77a..0c18f027f 100644 --- a/src/platform/workspace/composables/useWorkspace.ts +++ b/src/platform/workspace/composables/useWorkspace.ts @@ -1,8 +1,9 @@ -import { computed, ref } from 'vue' +import { computed, ref, shallowRef } from 'vue' import { useCurrentUser } from '@/composables/auth/useCurrentUser' -export type WorkspaceRole = 'PERSONAL' | 'MEMBER' | 'OWNER' +type WorkspaceRole = 'PERSONAL' | 'MEMBER' | 'OWNER' +type SubscriptionPlan = 'PRO_MONTHLY' | 'PRO_YEARLY' | null export interface WorkspaceMember { id: string @@ -20,10 +21,14 @@ export interface PendingInvite { inviteLink: string } -interface WorkspaceMockData { +interface Workspace { id: string | null name: string role: WorkspaceRole + isSubscribed: boolean + subscriptionPlan: SubscriptionPlan + members: WorkspaceMember[] + pendingInvites: PendingInvite[] } export interface AvailableWorkspace { @@ -33,7 +38,7 @@ export interface AvailableWorkspace { } /** Permission flags for workspace actions */ -export interface WorkspacePermissions { +interface WorkspacePermissions { canViewOtherMembers: boolean canViewPendingInvites: boolean canInviteMembers: boolean @@ -45,7 +50,7 @@ export interface WorkspacePermissions { } /** UI configuration for workspace role */ -export interface WorkspaceUIConfig { +interface WorkspaceUIConfig { showMembersList: boolean showPendingTab: boolean showSearch: boolean @@ -54,6 +59,7 @@ export interface WorkspaceUIConfig { membersGridCols: string pendingGridCols: string headerGridCols: string + showEditWorkspaceMenuItem: boolean workspaceMenuAction: 'leave' | 'delete' | null workspaceMenuDisabledTooltip: string | null } @@ -66,7 +72,7 @@ const ROLE_PERMISSIONS: Record = { canManageInvites: false, canRemoveMembers: false, canLeaveWorkspace: false, - canAccessWorkspaceMenu: false, + canAccessWorkspaceMenu: true, canManageSubscription: true }, MEMBER: { @@ -101,6 +107,7 @@ const ROLE_UI_CONFIG: Record = { membersGridCols: 'grid-cols-1', pendingGridCols: 'grid-cols-[50%_20%_20%_10%]', headerGridCols: 'grid-cols-1', + showEditWorkspaceMenuItem: true, workspaceMenuAction: null, workspaceMenuDisabledTooltip: null }, @@ -113,6 +120,7 @@ const ROLE_UI_CONFIG: Record = { membersGridCols: 'grid-cols-[1fr_auto]', pendingGridCols: 'grid-cols-[50%_20%_20%_10%]', headerGridCols: 'grid-cols-[1fr_auto]', + showEditWorkspaceMenuItem: false, workspaceMenuAction: 'leave', workspaceMenuDisabledTooltip: null }, @@ -125,129 +133,202 @@ const ROLE_UI_CONFIG: Record = { 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' } } -const MOCK_DATA: Record = { - PERSONAL: { +const MAX_OWNED_WORKSPACES = 10 +const MAX_WORKSPACE_MEMBERS = 50 + +function generateId(): string { + return Math.random().toString(36).substring(2, 10) +} + +function createPersonalWorkspace(): Workspace { + return { id: null, - name: 'Personal', - role: 'PERSONAL' - }, - MEMBER: { - id: 'workspace-abc-123', - name: 'Acme Corp', - role: 'MEMBER' - }, - OWNER: { - id: 'workspace-xyz-789', - name: 'Acme Corp', - role: 'OWNER' + name: 'Personal workspace', + role: 'PERSONAL', + isSubscribed: true, + subscriptionPlan: null, + members: [], + pendingInvites: [] } } -/** Mock list of all available workspaces for the current user */ -const MOCK_AVAILABLE_WORKSPACES: AvailableWorkspace[] = [ - { id: null, name: 'Personal workspace', role: 'PERSONAL' }, - { id: 'workspace-comfy-001', name: 'Team Comfy', role: 'OWNER' }, - { id: 'workspace-orange-002', name: 'OrangeDesignStudio', role: 'MEMBER' }, - { id: 'workspace-001', name: 'Workspace001', role: 'MEMBER' }, - { id: 'workspace-002', name: 'Workspace002', role: 'MEMBER' } -] - -const MAX_OWNED_WORKSPACES = 10 - -const MOCK_MEMBERS: WorkspaceMember[] = [ - { - id: '1', - name: 'Alice', - email: 'alice@example.com', - joinDate: new Date('2025-11-15') - }, - { - id: '2', - name: 'Bob', - email: 'bob@example.com', - joinDate: new Date('2025-12-01') - }, - { - id: '3', - name: 'Charlie', - email: 'charlie@example.com', - joinDate: new Date('2026-01-05') - } -] - -const MOCK_PENDING_INVITES: PendingInvite[] = [ - { - id: '1', - name: 'John', - email: 'john@gmail.com', - inviteDate: new Date('2026-01-02'), - expiryDate: new Date('2026-01-09'), - inviteLink: 'https://example.com/invite/abc123' - }, - { - id: '2', - name: 'User102', - email: 'user102@gmail.com', - inviteDate: new Date('2026-01-01'), - expiryDate: new Date('2026-01-08'), - inviteLink: 'https://example.com/invite/def456' - }, - { - id: '3', - name: 'User944', - email: 'user944@gmail.com', - inviteDate: new Date('2026-01-01'), - expiryDate: new Date('2026-01-08'), - inviteLink: 'https://example.com/invite/ghi789' - }, - { - id: '4', - name: 'User45', - email: 'user45@gmail.com', - inviteDate: new Date('2025-12-15'), - expiryDate: new Date('2025-12-22'), - inviteLink: 'https://example.com/invite/jkl012' - }, - { - id: '5', - name: 'User944', - email: 'user944@gmail.com', - inviteDate: new Date('2025-12-05'), - expiryDate: new Date('2025-12-22'), - inviteLink: 'https://example.com/invite/mno345' - } -] - -// Constants -const MAX_WORKSPACE_MEMBERS = 50 - -// Shared state for workspace -const _workspaceId = ref(null) -const _workspaceName = ref('Personal workspace') -const _workspaceRole = ref('PERSONAL') -const _isWorkspaceSubscribed = ref(true) +// Global state - start with personal workspace only +const _workspaces = shallowRef([createPersonalWorkspace()]) +const _currentWorkspaceIndex = ref(0) const _activeTab = ref('plan') -const _members = ref([]) -const _pendingInvites = ref([]) -const _availableWorkspaces = ref( - MOCK_AVAILABLE_WORKSPACES -) + +// Helper to get current workspace +function getCurrentWorkspace(): Workspace { + return _workspaces.value[_currentWorkspaceIndex.value] +} + +// Helper to update current workspace +function updateCurrentWorkspace(updates: Partial) { + const index = _currentWorkspaceIndex.value + const updated = { ..._workspaces.value[index], ...updates } + _workspaces.value = [ + ..._workspaces.value.slice(0, index), + updated, + ..._workspaces.value.slice(index + 1) + ] +} + +/** + * Switch to a different workspace + */ +function switchWorkspace(workspace: AvailableWorkspace) { + const index = _workspaces.value.findIndex((w) => w.id === workspace.id) + if (index !== -1) { + _currentWorkspaceIndex.value = index + } +} + +/** + * Create a new workspace + */ +function createNewWorkspace(name: string): Workspace { + const newWorkspace: Workspace = { + id: `workspace-${generateId()}`, + name, + role: 'OWNER', + isSubscribed: false, + subscriptionPlan: null, + members: [], + pendingInvites: [ + // Add one pending invite for testing revoke + { + 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] + // Switch to the new workspace + _currentWorkspaceIndex.value = _workspaces.value.length - 1 + + return newWorkspace +} + +/** + * Subscribe the current workspace to a plan + */ +function subscribeCurrentWorkspace(plan: SubscriptionPlan = 'PRO_MONTHLY') { + updateCurrentWorkspace({ + isSubscribed: true, + subscriptionPlan: plan + }) +} + +/** + * Delete the current workspace (OWNER only) + */ +function deleteCurrentWorkspace() { + const current = getCurrentWorkspace() + if (current.role === 'OWNER') { + _workspaces.value = _workspaces.value.filter((w) => w.id !== current.id) + _currentWorkspaceIndex.value = 0 + } +} + +/** + * Leave the current workspace (MEMBER only) + */ +function leaveCurrentWorkspace() { + const current = getCurrentWorkspace() + if (current.role === 'MEMBER') { + _workspaces.value = _workspaces.value.filter((w) => w.id !== current.id) + _currentWorkspaceIndex.value = 0 + } +} + +/** + * Add a member to the current workspace + */ +function addMemberToWorkspace( + member: Omit +) { + const current = getCurrentWorkspace() + const newMember: WorkspaceMember = { + ...member, + id: generateId(), + joinDate: new Date() + } + updateCurrentWorkspace({ + members: [...current.members, newMember] + }) +} + +/** + * Remove a member from the current workspace + */ +function removeMemberFromWorkspace(memberId: string) { + const current = getCurrentWorkspace() + updateCurrentWorkspace({ + members: current.members.filter((m) => m.id !== memberId) + }) +} + +/** + * Update the current workspace name + */ +function updateWorkspaceNameFn(name: string) { + updateCurrentWorkspace({ name }) +} + +/** + * Revoke a pending invite + */ +function revokePendingInvite(inviteId: string) { + const current = getCurrentWorkspace() + updateCurrentWorkspace({ + pendingInvites: current.pendingInvites.filter((i) => i.id !== inviteId) + }) +} + +/** + * Accept a pending invite (move to active members) + * For demo: simulates user accepting the invite + */ +function acceptPendingInvite(inviteId: string) { + const current = getCurrentWorkspace() + const invite = current.pendingInvites.find((i) => i.id === inviteId) + if (invite) { + // Remove from pending + const updatedPending = current.pendingInvites.filter( + (i) => i.id !== inviteId + ) + // Add to active members + const newMember: WorkspaceMember = { + id: generateId(), + name: invite.name, + email: invite.email, + joinDate: new Date() + } + updateCurrentWorkspace({ + pendingInvites: updatedPending, + members: [...current.members, newMember] + }) + } +} /** * Set workspace mock state for testing UI * Usage in browser console: window.__setWorkspaceRole('OWNER') */ function setMockRole(role: WorkspaceRole) { - const data = MOCK_DATA[role] - _workspaceId.value = data.id - _workspaceName.value = data.name - _workspaceRole.value = data.role + updateCurrentWorkspace({ role }) } /** @@ -255,19 +336,7 @@ function setMockRole(role: WorkspaceRole) { * Usage in browser console: window.__setWorkspaceSubscribed(false) */ function setMockSubscribed(subscribed: boolean) { - _isWorkspaceSubscribed.value = subscribed -} - -/** - * Switch to a different workspace - */ -function switchWorkspace(workspace: AvailableWorkspace) { - _workspaceId.value = workspace.id - _workspaceName.value = workspace.name - _workspaceRole.value = workspace.role - // Reset members/invites when switching - _members.value = [] - _pendingInvites.value = [] + updateCurrentWorkspace({ isSubscribed: subscribed }) } // Expose to window for dev testing @@ -290,43 +359,45 @@ if (typeof window !== 'undefined') { export function useWorkspace() { const { userDisplayName, userEmail } = useCurrentUser() - const workspaceId = computed(() => _workspaceId.value) - const workspaceName = computed(() => _workspaceName.value) - const workspaceRole = computed(() => _workspaceRole.value) + // Computed from current workspace + const currentWorkspace = computed(() => getCurrentWorkspace()) + const workspaceId = computed(() => currentWorkspace.value?.id ?? null) + const workspaceName = computed( + () => currentWorkspace.value?.name ?? 'Personal workspace' + ) + const workspaceRole = computed( + () => currentWorkspace.value?.role ?? 'PERSONAL' + ) const activeTab = computed(() => _activeTab.value) const isPersonalWorkspace = computed( - () => _workspaceRole.value === 'PERSONAL' + () => currentWorkspace.value?.role === 'PERSONAL' ) - const isWorkspaceSubscribed = computed(() => _isWorkspaceSubscribed.value) + const isWorkspaceSubscribed = computed( + () => currentWorkspace.value?.isSubscribed ?? false + ) + + const subscriptionPlan = computed( + () => currentWorkspace.value?.subscriptionPlan ?? null + ) const permissions = computed( - () => ROLE_PERMISSIONS[_workspaceRole.value] + () => ROLE_PERMISSIONS[workspaceRole.value] ) const uiConfig = computed( - () => ROLE_UI_CONFIG[_workspaceRole.value] + () => ROLE_UI_CONFIG[workspaceRole.value] ) function setActiveTab(tab: string | number) { _activeTab.value = String(tab) } - const members = computed(() => _members.value) - const pendingInvites = computed(() => _pendingInvites.value) - - const totalMemberSlots = computed( - () => _members.value.length + _pendingInvites.value.length - ) - const isInviteLimitReached = computed( - () => totalMemberSlots.value >= MAX_WORKSPACE_MEMBERS - ) - - // TODO: Replace with actual API calls - async function fetchMembers(): Promise { - if (_workspaceRole.value === 'PERSONAL') { - _members.value = [ + // For personal workspace, always show current user as the only member + const members = computed(() => { + if (isPersonalWorkspace.value) { + return [ { id: 'current-user', name: userDisplayName.value ?? 'You', @@ -334,27 +405,45 @@ export function useWorkspace() { joinDate: new Date() } ] - } else { - _members.value = MOCK_MEMBERS } - return _members.value + 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 + ) + + // Fetch members - returns current user for personal workspace + async function fetchMembers(): Promise { + if (isPersonalWorkspace.value) { + return [ + { + id: 'current-user', + name: userDisplayName.value ?? 'You', + email: userEmail.value ?? '', + joinDate: new Date() + } + ] + } + return members.value } async function fetchPendingInvites(): Promise { - if (_workspaceRole.value === 'PERSONAL') { - _pendingInvites.value = [] - } else { - _pendingInvites.value = MOCK_PENDING_INVITES - } - return _pendingInvites.value + return pendingInvites.value } - async function revokeInvite(_inviteId: string): Promise { - // TODO: API call to revoke invite + async function revokeInvite(inviteId: string): Promise { + revokePendingInvite(inviteId) } async function copyInviteLink(inviteId: string): Promise { - const invite = _pendingInvites.value.find((i) => i.id === inviteId) + const invite = pendingInvites.value.find((i) => i.id === inviteId) if (invite) { await navigator.clipboard.writeText(invite.inviteLink) return invite.inviteLink @@ -362,35 +451,63 @@ export function useWorkspace() { throw new Error('Invite not found') } + /** + * Copy invite link and simulate member accepting (for demo) + * When copy link is clicked, the invited user "accepts" and becomes a member + */ + async function copyInviteLinkAndAccept(inviteId: string): Promise { + const invite = pendingInvites.value.find((i) => i.id === inviteId) + if (invite) { + await navigator.clipboard.writeText(invite.inviteLink) + + // Simulate user accepting invite: move from pending to active + revokePendingInvite(inviteId) + addMemberToWorkspace({ + name: invite.name, + email: invite.email + }) + + return invite.inviteLink + } + throw new Error('Invite not found') + } + /** * Create an invite link for a given email - * TODO: Replace with actual API call */ async function createInviteLink(email: string): Promise { // Simulate API delay - await new Promise((resolve) => setTimeout(resolve, 1000)) + await new Promise((resolve) => setTimeout(resolve, 500)) - // Generate mock invite link - const inviteId = Math.random().toString(36).substring(2, 10) - const inviteLink = `https://cloud.comfy.org/workspace?3423532/invite/hi789jkl012mno345pq` + const inviteId = generateId() + const inviteLink = `https://cloud.comfy.org/workspace/invite/${inviteId}` - // Add to pending invites (mock) + // Add to pending invites + 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), // 7 days + expiryDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), inviteLink } - _pendingInvites.value = [..._pendingInvites.value, newInvite] + updateCurrentWorkspace({ + pendingInvites: [...current.pendingInvites, newInvite] + }) return inviteLink } - const availableWorkspaces = computed(() => _availableWorkspaces.value) + const availableWorkspaces = computed(() => + _workspaces.value.map((w) => ({ + id: w.id, + name: w.name, + role: w.role + })) + ) const ownedWorkspacesCount = computed( - () => _availableWorkspaces.value.filter((w) => w.role === 'OWNER').length + () => _workspaces.value.filter((w) => w.role === 'OWNER').length ) const canCreateWorkspace = computed( () => ownedWorkspacesCount.value < MAX_OWNED_WORKSPACES @@ -403,6 +520,7 @@ export function useWorkspace() { activeTab, isPersonalWorkspace, isWorkspaceSubscribed, + subscriptionPlan, permissions, uiConfig, setActiveTab, @@ -411,6 +529,11 @@ export function useWorkspace() { ownedWorkspacesCount, canCreateWorkspace, switchWorkspace, + // Workspace management + createWorkspace: createNewWorkspace, + subscribeWorkspace: subscribeCurrentWorkspace, + deleteWorkspace: deleteCurrentWorkspace, + leaveWorkspace: leaveCurrentWorkspace, // Members members, pendingInvites, @@ -419,8 +542,13 @@ export function useWorkspace() { fetchMembers, fetchPendingInvites, revokeInvite, + acceptInvite: acceptPendingInvite, copyInviteLink, + copyInviteLinkAndAccept, createInviteLink, + addMember: addMemberToWorkspace, + removeMember: removeMemberFromWorkspace, + updateWorkspaceName: updateWorkspaceNameFn, // Dev helpers setMockRole, setMockSubscribed diff --git a/src/services/dialogService.ts b/src/services/dialogService.ts index 944c915a6..c72ca8658 100644 --- a/src/services/dialogService.ts +++ b/src/services/dialogService.ts @@ -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) { + function showLeaveWorkspaceDialog() { return dialogStore.showDialog({ key: 'leave-workspace', component: LeaveWorkspaceDialogContent, - props: { onConfirm }, dialogComponentProps: { headless: true, pt: { @@ -542,11 +542,10 @@ export const useDialogService = () => { }) } - function showDeleteWorkspaceDialog(onConfirm: () => void | Promise) { + function showDeleteWorkspaceDialog() { return dialogStore.showDialog({ key: 'delete-workspace', component: DeleteWorkspaceDialogContent, - props: { onConfirm }, dialogComponentProps: { headless: true, pt: { @@ -558,11 +557,11 @@ export const useDialogService = () => { }) } - function showRemoveMemberDialog(onConfirm: () => void | Promise) { + function showRemoveMemberDialog(memberId: string) { return dialogStore.showDialog({ key: 'remove-member', component: RemoveMemberDialogContent, - props: { onConfirm }, + props: { memberId }, dialogComponentProps: { headless: true, pt: { @@ -574,11 +573,11 @@ export const useDialogService = () => { }) } - function showRevokeInviteDialog(onConfirm: () => void | Promise) { + function showRevokeInviteDialog(inviteId: string) { return dialogStore.showDialog({ key: 'revoke-invite', component: RevokeInviteDialogContent, - props: { onConfirm }, + props: { inviteId }, dialogComponentProps: { headless: true, pt: { @@ -626,6 +625,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 +657,7 @@ export const useDialogService = () => { showRevokeInviteDialog, showInviteMemberDialog, showCreateWorkspaceDialog, + showEditWorkspaceDialog, showExtensionDialog, prompt, showErrorDialog,