feat: add workspace API and refactor useWorkspace

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
--list
2026-01-15 17:29:15 -08:00
parent 8950b7327f
commit 99d91dd53b
8 changed files with 671 additions and 286 deletions

View File

@@ -457,9 +457,7 @@ function handleRevokeInvite(invite: PendingInvite) {
}
function handleCreateWorkspace() {
showCreateWorkspaceDialog(() => {
// TODO: Implement actual create workspace API call
})
showCreateWorkspaceDialog()
}
function handleRemoveMember(_member: WorkspaceMember) {

View File

@@ -1,4 +1,4 @@
<!-- A button that shows current authenticated user's avatar -->
<!-- A button that shows current workspace's profile picture -->
<template>
<div>
<Button
@@ -16,7 +16,10 @@
)
"
>
<UserAvatar :photo-url="photoURL" :class="compact && 'size-full'" />
<WorkspaceProfilePic
:workspace-name="workspaceName"
:class="compact && 'size-full'"
/>
<i v-if="showArrow" class="icon-[lucide--chevron-down] size-3 px-1" />
</div>
@@ -38,11 +41,12 @@
<script setup lang="ts">
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import { ref } from 'vue'
import UserAvatar from '@/components/common/UserAvatar.vue'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useWorkspace } from '@/platform/workspace/composables/useWorkspace'
import { cn } from '@/utils/tailwindUtil'
import CurrentUserPopover from './CurrentUserPopover.vue'
@@ -52,12 +56,10 @@ const { showArrow = true, compact = false } = defineProps<{
compact?: boolean
}>()
const { isLoggedIn, userPhotoUrl } = useCurrentUser()
const { isLoggedIn } = useCurrentUser()
const { workspaceName } = useWorkspace()
const popover = ref<InstanceType<typeof Popover> | null>(null)
const photoURL = computed<string | undefined>(
() => userPhotoUrl.value ?? undefined
)
const closePopover = () => {
popover.value?.hide()

View File

@@ -104,7 +104,7 @@
<!-- OWNER unsubscribed: Show Subscribe button (primary) -->
<div
v-else-if="workspaceRole === 'OWNER' && !isWorkspaceSubscribed"
v-else-if="workspaceRole === 'owner' && !isWorkspaceSubscribed"
class="flex justify-center px-4 py-2"
>
<Button
@@ -298,13 +298,13 @@ const canUpgrade = computed(() => {
// OWNER (unsubscribed): Plans & pricing, Partner nodes, Divider, Workspace settings, Account settings, Divider, Log out
// OWNER (subscribed): Plans & pricing, Manage plan, Partner nodes, Divider, Workspace settings, Account settings, Divider, Log out
const showPlansAndPricing = computed(
() => isPersonalWorkspace.value || workspaceRole.value === 'OWNER'
() => isPersonalWorkspace.value || workspaceRole.value === 'owner'
)
const showManagePlan = computed(
() => showPlansAndPricing.value && isActiveSubscription.value
)
const showCreditsSection = computed(
() => isPersonalWorkspace.value || workspaceRole.value === 'OWNER'
() => isPersonalWorkspace.value || workspaceRole.value === 'owner'
)
const handleOpenUserSettings = () => {
@@ -358,9 +358,7 @@ const handleSubscribed = async () => {
const handleCreateWorkspace = () => {
workspaceSwitcherPopover.value?.hide()
dialogService.showCreateWorkspaceDialog(() => {
// TODO: Implement actual create workspace API call
})
dialogService.showCreateWorkspaceDialog()
emit('close')
}

View File

@@ -25,7 +25,7 @@
{{ workspace.name }}
</span>
<span
v-if="workspace.role !== 'PERSONAL'"
v-if="workspace.type !== 'personal'"
class="text-sm text-muted-foreground"
>
{{ getRoleLabel(workspace.role) }}
@@ -107,8 +107,8 @@ function isCurrentWorkspace(workspace: AvailableWorkspace): boolean {
}
function getRoleLabel(role: AvailableWorkspace['role']): string {
if (role === 'OWNER') return t('workspaceSwitcher.roleOwner')
if (role === 'MEMBER') return t('workspaceSwitcher.roleMember')
if (role === 'owner') return t('workspaceSwitcher.roleOwner')
if (role === 'member') return t('workspaceSwitcher.roleMember')
return ''
}

View File

@@ -264,7 +264,7 @@ const { t, n } = useI18n()
// OWNER with unsubscribed workspace
const isOwnerUnsubscribed = computed(
() => workspaceRole.value === 'OWNER' && !isWorkspaceSubscribed.value
() => workspaceRole.value === 'owner' && !isWorkspaceSubscribed.value
)
const {

View File

@@ -0,0 +1,210 @@
import type { AxiosResponse } from 'axios'
import axios from 'axios'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { AuthHeader } from '@/types/authTypes'
// Types aligned with backend API (matching useWorkspaceAuth types)
export type WorkspaceType = 'personal' | 'team'
export type WorkspaceRole = 'owner' | 'member'
export interface Workspace {
id: string
name: string
type: WorkspaceType
}
export interface WorkspaceWithRole extends Workspace {
role: WorkspaceRole
}
export interface WorkspaceMember {
id: string
name: string
email: string
role: WorkspaceRole
joined_at: string
}
export interface PendingInvite {
id: string
email: 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 CreateWorkspacePayload {
name: string
}
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[]
}
export class WorkspaceApiError extends Error {
constructor(
message: string,
public readonly status?: number,
public readonly code?: string
) {
super(message)
this.name = 'WorkspaceApiError'
}
}
const workspaceApiClient = axios.create({
baseURL: getComfyApiBaseUrl(),
headers: {
'Content-Type': 'application/json'
}
})
async function withAuth<T>(
request: (headers: AuthHeader) => Promise<AxiosResponse<T>>
): Promise<T> {
const authHeader = await useFirebaseAuthStore().getAuthHeader()
if (!authHeader) {
throw new WorkspaceApiError(
t('toastMessages.userNotAuthenticated'),
401,
'NOT_AUTHENTICATED'
)
}
try {
const response = await request(authHeader)
return response.data
} catch (err) {
if (axios.isAxiosError(err)) {
const status = err.response?.status
const message = err.response?.data?.message ?? err.message
throw new WorkspaceApiError(message, status)
}
throw err
}
}
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 })
),
/**
* Create a new workspace
* POST /api/workspaces
*/
create: (payload: CreateWorkspacePayload): Promise<WorkspaceWithRole> =>
withAuth((headers) =>
workspaceApiClient.post('/workspaces', payload, { headers })
),
/**
* Update workspace name
* PATCH /api/workspaces/:id
*/
update: (
workspaceId: string,
payload: UpdateWorkspacePayload
): Promise<WorkspaceWithRole> =>
withAuth((headers) =>
workspaceApiClient.patch(`/workspaces/${workspaceId}`, payload, {
headers
})
),
/**
* Delete a workspace (owner only)
* DELETE /api/workspaces/:id
*/
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, {
headers
})
),
/**
* Create an invite link for a workspace
* POST /api/workspaces/:id/invites
*/
createInvite: (
workspaceId: string,
payload: CreateInvitePayload
): Promise<CreateInviteResponse> =>
withAuth((headers) =>
workspaceApiClient.post(`/workspaces/${workspaceId}/invites`, payload, {
headers
})
),
/**
* Revoke a pending invite
* DELETE /api/workspaces/:id/invites/:inviteId
*/
revokeInvite: (workspaceId: string, inviteId: string): Promise<void> =>
withAuth((headers) =>
workspaceApiClient.delete(
`/workspaces/${workspaceId}/invites/${inviteId}`,
{ headers }
)
),
/**
* Remove a member from workspace
* DELETE /api/workspaces/:id/members/:memberId
*/
removeMember: (workspaceId: string, memberId: string): Promise<void> =>
withAuth((headers) =>
workspaceApiClient.delete(
`/workspaces/${workspaceId}/members/${memberId}`,
{ headers }
)
)
}

View File

@@ -1,16 +1,26 @@
import { computed, ref } from 'vue'
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'
export type WorkspaceRole = 'PERSONAL' | 'MEMBER' | 'OWNER'
// 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
@@ -20,20 +30,24 @@ export interface PendingInvite {
inviteLink: string
}
interface WorkspaceMockData {
id: string | null
name: string
role: WorkspaceRole
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 */
export interface WorkspacePermissions {
interface WorkspacePermissions {
canViewOtherMembers: boolean
canViewPendingInvites: boolean
canInviteMembers: boolean
@@ -45,7 +59,7 @@ export interface WorkspacePermissions {
}
/** UI configuration for workspace role */
export interface WorkspaceUIConfig {
interface WorkspaceUIConfig {
showMembersList: boolean
showPendingTab: boolean
showSearch: boolean
@@ -54,22 +68,45 @@ export interface WorkspaceUIConfig {
membersGridCols: string
pendingGridCols: string
headerGridCols: string
showEditWorkspaceMenuItem: boolean
workspaceMenuAction: 'leave' | 'delete' | null
workspaceMenuDisabledTooltip: string | null
}
const ROLE_PERMISSIONS: Record<WorkspaceRole, WorkspacePermissions> = {
PERSONAL: {
canViewOtherMembers: false,
canViewPendingInvites: false,
canInviteMembers: false,
canManageInvites: false,
canRemoveMembers: false,
canLeaveWorkspace: false,
canAccessWorkspaceMenu: false,
canManageSubscription: true
},
MEMBER: {
// 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,
@@ -78,33 +115,48 @@ const ROLE_PERMISSIONS: Record<WorkspaceRole, WorkspacePermissions> = {
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: false
},
OWNER: {
canViewOtherMembers: true,
canViewPendingInvites: true,
canInviteMembers: true,
canManageInvites: true,
canRemoveMembers: true,
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: true
}
}
const ROLE_UI_CONFIG: Record<WorkspaceRole, WorkspaceUIConfig> = {
PERSONAL: {
showMembersList: false,
showPendingTab: false,
showSearch: false,
showDateColumn: false,
showRoleBadge: false,
membersGridCols: 'grid-cols-1',
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
headerGridCols: 'grid-cols-1',
workspaceMenuAction: null,
workspaceMenuDisabledTooltip: null
},
MEMBER: {
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,
@@ -113,248 +165,308 @@ const ROLE_UI_CONFIG: Record<WorkspaceRole, WorkspaceUIConfig> = {
membersGridCols: 'grid-cols-[1fr_auto]',
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
headerGridCols: 'grid-cols-[1fr_auto]',
showEditWorkspaceMenuItem: false,
workspaceMenuAction: 'leave',
workspaceMenuDisabledTooltip: null
},
OWNER: {
showMembersList: true,
showPendingTab: true,
showSearch: true,
showDateColumn: true,
showRoleBadge: true,
membersGridCols: 'grid-cols-[50%_40%_10%]',
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
headerGridCols: 'grid-cols-[50%_40%_10%]',
workspaceMenuAction: 'delete',
workspaceMenuDisabledTooltip:
'workspacePanel.menu.deleteWorkspaceDisabledTooltip'
}
}
const MOCK_DATA: Record<WorkspaceRole, WorkspaceMockData> = {
PERSONAL: {
id: null,
name: 'Personal',
role: 'PERSONAL'
},
MEMBER: {
id: 'workspace-abc-123',
name: 'Acme Corp',
role: 'MEMBER'
},
OWNER: {
id: 'workspace-xyz-789',
name: 'Acme Corp',
role: 'OWNER'
}
}
/** Mock list of all available workspaces for the current user */
const MOCK_AVAILABLE_WORKSPACES: AvailableWorkspace[] = [
{ id: null, name: 'Personal workspace', role: 'PERSONAL' },
{ id: 'workspace-comfy-001', name: 'Team Comfy', role: 'OWNER' },
{ id: 'workspace-orange-002', name: 'OrangeDesignStudio', role: 'MEMBER' },
{ id: 'workspace-001', name: 'Workspace001', role: 'MEMBER' },
{ id: 'workspace-002', name: 'Workspace002', role: 'MEMBER' }
]
const MAX_OWNED_WORKSPACES = 10
const MOCK_MEMBERS: WorkspaceMember[] = [
{
id: '1',
name: 'Alice',
email: 'alice@example.com',
joinDate: new Date('2025-11-15')
},
{
id: '2',
name: 'Bob',
email: 'bob@example.com',
joinDate: new Date('2025-12-01')
},
{
id: '3',
name: 'Charlie',
email: 'charlie@example.com',
joinDate: new Date('2026-01-05')
}
]
const MOCK_PENDING_INVITES: PendingInvite[] = [
{
id: '1',
name: 'John',
email: 'john@gmail.com',
inviteDate: new Date('2026-01-02'),
expiryDate: new Date('2026-01-09'),
inviteLink: 'https://example.com/invite/abc123'
},
{
id: '2',
name: 'User102',
email: 'user102@gmail.com',
inviteDate: new Date('2026-01-01'),
expiryDate: new Date('2026-01-08'),
inviteLink: 'https://example.com/invite/def456'
},
{
id: '3',
name: 'User944',
email: 'user944@gmail.com',
inviteDate: new Date('2026-01-01'),
expiryDate: new Date('2026-01-08'),
inviteLink: 'https://example.com/invite/ghi789'
},
{
id: '4',
name: 'User45',
email: 'user45@gmail.com',
inviteDate: new Date('2025-12-15'),
expiryDate: new Date('2025-12-22'),
inviteLink: 'https://example.com/invite/jkl012'
},
{
id: '5',
name: 'User944',
email: 'user944@gmail.com',
inviteDate: new Date('2025-12-05'),
expiryDate: new Date('2025-12-22'),
inviteLink: 'https://example.com/invite/mno345'
}
]
// Constants
const MAX_WORKSPACE_MEMBERS = 50
// Shared state for workspace
const _workspaceId = ref<string | null>(null)
const _workspaceName = ref<string>('Personal workspace')
const _workspaceRole = ref<WorkspaceRole>('PERSONAL')
const _isWorkspaceSubscribed = ref<boolean>(true)
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')
const _members = ref<WorkspaceMember[]>([])
const _pendingInvites = ref<PendingInvite[]>([])
const _availableWorkspaces = ref<AvailableWorkspace[]>(
MOCK_AVAILABLE_WORKSPACES
)
/**
* Set workspace mock state for testing UI
* Usage in browser console: window.__setWorkspaceRole('OWNER')
*/
function setMockRole(role: WorkspaceRole) {
const data = MOCK_DATA[role]
_workspaceId.value = data.id
_workspaceName.value = data.name
_workspaceRole.value = data.role
// 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)
]
}
/**
* Set workspace subscription state for testing UI
* Usage in browser console: window.__setWorkspaceSubscribed(false)
* Internal composable implementation for workspace management.
* Uses module-level state to persist across component lifecycle.
* Will integrate with useWorkspaceAuth once that PR lands.
*/
function setMockSubscribed(subscribed: boolean) {
_isWorkspaceSubscribed.value = subscribed
}
/**
* Switch to a different workspace
*/
function switchWorkspace(workspace: AvailableWorkspace) {
_workspaceId.value = workspace.id
_workspaceName.value = workspace.name
_workspaceRole.value = workspace.role
// Reset members/invites when switching
_members.value = []
_pendingInvites.value = []
}
// Expose to window for dev testing
if (typeof window !== 'undefined') {
;(
window as Window & {
__setWorkspaceRole?: typeof setMockRole
__setWorkspaceSubscribed?: typeof setMockSubscribed
}
).__setWorkspaceRole = setMockRole
;(
window as Window & { __setWorkspaceSubscribed?: typeof setMockSubscribed }
).__setWorkspaceSubscribed = setMockSubscribed
}
/**
* Composable for handling workspace data
* TODO: Replace stubbed data with actual API call
*/
export function useWorkspace() {
function useWorkspaceInternal() {
const { userDisplayName, userEmail } = useCurrentUser()
const workspaceId = computed(() => _workspaceId.value)
const workspaceName = computed(() => _workspaceName.value)
const workspaceRole = computed(() => _workspaceRole.value)
// 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(
() => _workspaceRole.value === 'PERSONAL'
() => currentWorkspace.value?.type === 'personal'
)
const isWorkspaceSubscribed = computed(() => _isWorkspaceSubscribed.value)
const permissions = computed<WorkspacePermissions>(
() => ROLE_PERMISSIONS[_workspaceRole.value]
const isWorkspaceSubscribed = computed(
() => currentWorkspace.value?.isSubscribed ?? false
)
const uiConfig = computed<WorkspaceUIConfig>(
() => ROLE_UI_CONFIG[_workspaceRole.value]
const subscriptionPlan = computed(
() => currentWorkspace.value?.subscriptionPlan ?? null
)
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
const permissions = computed<WorkspacePermissions>(() =>
getPermissions(workspaceType.value, workspaceRole.value)
)
// TODO: Replace with actual API calls
async function fetchMembers(): Promise<WorkspaceMember[]> {
if (_workspaceRole.value === 'PERSONAL') {
_members.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()
}
]
} 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
)
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[]> {
if (_workspaceRole.value === 'PERSONAL') {
_pendingInvites.value = []
} else {
_pendingInvites.value = MOCK_PENDING_INVITES
}
return _pendingInvites.value
}
async function revokeInvite(_inviteId: string): Promise<void> {
// TODO: API call to revoke invite
// 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)
const invite = pendingInvites.value.find((i) => i.id === inviteId)
if (invite) {
await navigator.clipboard.writeText(invite.inviteLink)
return invite.inviteLink
@@ -363,54 +475,101 @@ export function useWorkspace() {
}
/**
* Create an invite link for a given email
* TODO: Replace with actual API call
* 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> {
// 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)
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 ownedWorkspacesCount = computed(
() => _availableWorkspaces.value.filter((w) => w.role === 'OWNER').length
)
const canCreateWorkspace = computed(
() => ownedWorkspacesCount.value < MAX_OWNED_WORKSPACES
)
// 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
// Workspace switching/management
availableWorkspaces,
ownedWorkspacesCount,
canCreateWorkspace,
switchWorkspace,
createWorkspace,
subscribeWorkspace,
deleteWorkspace,
leaveWorkspace,
updateWorkspaceName,
// Members
members,
pendingInvites,
@@ -419,10 +578,28 @@ export function useWorkspace() {
fetchMembers,
fetchPendingInvites,
revokeInvite,
acceptInvite,
copyInviteLink,
copyInviteLinkAndAccept,
createInviteLink,
addMember,
removeMember,
// Dev helpers
setMockRole,
setMockSubscribed
setMockSubscribed,
setMockType
}
}
/**
* Shared composable for workspace management.
* Uses module-level state to persist across component lifecycle.
* The createSharedComposable wrapper ensures computed properties
* are shared efficiently across components.
*
* Future integration:
* - Will consume useWorkspaceAuth for authentication context
* - Will use workspaceApi for backend calls
*/
export const useWorkspace = createSharedComposable(useWorkspaceInternal)

View File

@@ -609,7 +609,7 @@ export const useDialogService = () => {
}
function showCreateWorkspaceDialog(
onConfirm: (name: string) => void | Promise<void>
onConfirm?: (name: string) => void | Promise<void>
) {
return dialogStore.showDialog({
key: 'create-workspace',