mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-15 01:48:06 +00:00
Compare commits
1 Commits
fix/codera
...
fix(worksp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e2e517cc1 |
20
src/composables/billing/formatBalance.ts
Normal file
20
src/composables/billing/formatBalance.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
|
||||
/**
|
||||
* Formats a cent value to display credits.
|
||||
* Backend returns cents despite the *_micros naming convention.
|
||||
*/
|
||||
export function formatBalance(
|
||||
maybeCents: number | undefined,
|
||||
locale: string
|
||||
): string {
|
||||
const cents = maybeCents ?? 0
|
||||
return formatCreditsFromCents({
|
||||
cents,
|
||||
locale,
|
||||
numberOptions: {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}
|
||||
})
|
||||
}
|
||||
375
src/platform/auth/workspace/workspaceAuthStore.ts
Normal file
375
src/platform/auth/workspace/workspaceAuthStore.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
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 { api } from '@/scripts/api'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import type { AuthHeader } from '@/types/authTypes'
|
||||
import type { WorkspaceWithRole } from '@/platform/auth/workspace/workspaceTypes'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
|
||||
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', () => {
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
// 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 (!flags.teamWorkspacesEnabled) {
|
||||
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 (!flags.teamWorkspacesEnabled) {
|
||||
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
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[400px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
{{ $t('subscription.cancelDialog.title') }}
|
||||
</h2>
|
||||
<button
|
||||
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
|
||||
:aria-label="$t('g.close')"
|
||||
:disabled="isLoading"
|
||||
@click="onClose"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex flex-col gap-4 px-4 py-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-4">
|
||||
<Button variant="muted-textonly" :disabled="isLoading" @click="onClose">
|
||||
{{ $t('subscription.cancelDialog.keepSubscription') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
:loading="isLoading"
|
||||
@click="onConfirmCancel"
|
||||
>
|
||||
{{ $t('subscription.cancelDialog.confirmCancel') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const props = defineProps<{
|
||||
cancelAt?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
const toast = useToast()
|
||||
const { cancelSubscription, fetchStatus, subscription } = useBillingContext()
|
||||
|
||||
const isLoading = ref(false)
|
||||
|
||||
const formattedEndDate = computed(() => {
|
||||
const dateStr = props.cancelAt ?? subscription.value?.endDate
|
||||
if (!dateStr) return t('subscription.cancelDialog.endOfBillingPeriod')
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
})
|
||||
|
||||
const description = computed(() =>
|
||||
t('subscription.cancelDialog.description', { date: formattedEndDate.value })
|
||||
)
|
||||
|
||||
function onClose() {
|
||||
if (isLoading.value) return
|
||||
dialogStore.closeDialog({ key: 'cancel-subscription' })
|
||||
}
|
||||
|
||||
async function onConfirmCancel() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
await cancelSubscription()
|
||||
await fetchStatus()
|
||||
dialogStore.closeDialog({ key: 'cancel-subscription' })
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('subscription.cancelSuccess'),
|
||||
life: 5000
|
||||
})
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('subscription.cancelDialog.failed'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
20
src/platform/cloud/subscription/utils/tierDisplayUtil.ts
Normal file
20
src/platform/cloud/subscription/utils/tierDisplayUtil.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
|
||||
const EXTENDED_TIER_TO_KEY: Record<string, TierKey> = {
|
||||
...TIER_TO_KEY,
|
||||
FOUNDER: 'founder'
|
||||
}
|
||||
|
||||
export function formatTierName(
|
||||
tier: string | null | undefined,
|
||||
isYearly: boolean,
|
||||
t: (key: string, params?: Record<string, string>) => string
|
||||
): string {
|
||||
if (!tier) return ''
|
||||
const key = EXTENDED_TIER_TO_KEY[tier] ?? 'standard'
|
||||
const baseName = t(`subscription.tiers.${key}.name`)
|
||||
return isYearly
|
||||
? t('subscription.tierNameYearly', { name: baseName })
|
||||
: baseName
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[400px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
{{ $t('workspacePanel.createWorkspaceDialog.title') }}
|
||||
</h2>
|
||||
<button
|
||||
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="onCancel"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex flex-col gap-4 px-4 py-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.createWorkspaceDialog.message') }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-base-foreground">
|
||||
{{ $t('workspacePanel.createWorkspaceDialog.nameLabel') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="workspaceName"
|
||||
type="text"
|
||||
class="w-full rounded-lg border border-border-default bg-transparent px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-secondary-foreground"
|
||||
:placeholder="
|
||||
$t('workspacePanel.createWorkspaceDialog.namePlaceholder')
|
||||
"
|
||||
@keydown.enter="isValidName && onCreate()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-4">
|
||||
<Button variant="muted-textonly" @click="onCancel">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:loading
|
||||
:disabled="!isValidName"
|
||||
@click="onCreate"
|
||||
>
|
||||
{{ $t('workspacePanel.createWorkspaceDialog.create') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { onConfirm } = defineProps<{
|
||||
onConfirm?: (name: string) => void | Promise<void>
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
const toast = useToast()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const loading = ref(false)
|
||||
const workspaceName = ref('')
|
||||
|
||||
const isValidName = computed(() => {
|
||||
const name = workspaceName.value.trim()
|
||||
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_'.,()&+]*$/
|
||||
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
|
||||
})
|
||||
|
||||
function onCancel() {
|
||||
dialogStore.closeDialog({ key: 'create-workspace' })
|
||||
}
|
||||
|
||||
async function onCreate() {
|
||||
if (!isValidName.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const name = workspaceName.value.trim()
|
||||
// Call optional callback if provided
|
||||
await onConfirm?.(name)
|
||||
dialogStore.closeDialog({ key: 'create-workspace' })
|
||||
// Create workspace and switch to it (triggers reload internally)
|
||||
await workspaceStore.createWorkspace(name)
|
||||
} catch (error) {
|
||||
console.error('[CreateWorkspaceDialog] Failed to create workspace:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.toast.failedToCreateWorkspace'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[360px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
{{ $t('workspacePanel.deleteDialog.title') }}
|
||||
</h2>
|
||||
<button
|
||||
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="onCancel"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-4 py-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{
|
||||
workspaceName
|
||||
? $t('workspacePanel.deleteDialog.messageWithName', {
|
||||
name: workspaceName
|
||||
})
|
||||
: $t('workspacePanel.deleteDialog.message')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-4">
|
||||
<Button variant="muted-textonly" @click="onCancel">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button variant="destructive" size="lg" :loading @click="onDelete">
|
||||
{{ $t('g.delete') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { workspaceId, workspaceName } = defineProps<{
|
||||
workspaceId?: string
|
||||
workspaceName?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const dialogStore = useDialogStore()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const loading = ref(false)
|
||||
|
||||
function onCancel() {
|
||||
dialogStore.closeDialog({ key: 'delete-workspace' })
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
loading.value = true
|
||||
try {
|
||||
// Delete workspace (uses workspaceId if provided, otherwise current workspace)
|
||||
await workspaceStore.deleteWorkspace(workspaceId)
|
||||
dialogStore.closeDialog({ key: 'delete-workspace' })
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
console.error('[DeleteWorkspaceDialog] Failed to delete workspace:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.toast.failedToDeleteWorkspace'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
104
src/platform/workspace/components/EditWorkspaceDialogContent.vue
Normal file
104
src/platform/workspace/components/EditWorkspaceDialogContent.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[400px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
{{ $t('workspacePanel.editWorkspaceDialog.title') }}
|
||||
</h2>
|
||||
<button
|
||||
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="onCancel"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex flex-col gap-4 px-4 py-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-base-foreground">
|
||||
{{ $t('workspacePanel.editWorkspaceDialog.nameLabel') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="newWorkspaceName"
|
||||
type="text"
|
||||
class="w-full rounded-lg border border-border-default bg-transparent px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-secondary-foreground"
|
||||
@keydown.enter="isValidName && onSave()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-4">
|
||||
<Button variant="muted-textonly" @click="onCancel">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:loading
|
||||
:disabled="!isValidName"
|
||||
@click="onSave"
|
||||
>
|
||||
{{ $t('workspacePanel.editWorkspaceDialog.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const dialogStore = useDialogStore()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const loading = ref(false)
|
||||
const newWorkspaceName = ref(workspaceStore.workspaceName)
|
||||
|
||||
const isValidName = computed(() => {
|
||||
const name = newWorkspaceName.value.trim()
|
||||
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_'.,()&+]*$/
|
||||
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
|
||||
})
|
||||
|
||||
function onCancel() {
|
||||
dialogStore.closeDialog({ key: 'edit-workspace' })
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (!isValidName.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
await workspaceStore.updateWorkspaceName(newWorkspaceName.value.trim())
|
||||
dialogStore.closeDialog({ key: 'edit-workspace' })
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('workspacePanel.toast.workspaceUpdated.title'),
|
||||
detail: t('workspacePanel.toast.workspaceUpdated.message'),
|
||||
life: 5000
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[EditWorkspaceDialog] Failed to update workspace:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.toast.failedToUpdateWorkspace'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
182
src/platform/workspace/components/InviteMemberDialogContent.vue
Normal file
182
src/platform/workspace/components/InviteMemberDialogContent.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[512px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
{{
|
||||
step === 'email'
|
||||
? $t('workspacePanel.inviteMemberDialog.title')
|
||||
: $t('workspacePanel.inviteMemberDialog.linkStep.title')
|
||||
}}
|
||||
</h2>
|
||||
<button
|
||||
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="onCancel"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body: Email Step -->
|
||||
<template v-if="step === 'email'">
|
||||
<div class="flex flex-col gap-4 px-4 py-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.inviteMemberDialog.message') }}
|
||||
</p>
|
||||
<input
|
||||
v-model="email"
|
||||
type="email"
|
||||
class="w-full rounded-lg border border-border-default bg-transparent px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-secondary-foreground"
|
||||
:placeholder="$t('workspacePanel.inviteMemberDialog.placeholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Footer: Email Step -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-4">
|
||||
<Button variant="muted-textonly" @click="onCancel">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:loading
|
||||
:disabled="!isValidEmail"
|
||||
@click="onCreateLink"
|
||||
>
|
||||
{{ $t('workspacePanel.inviteMemberDialog.createLink') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Body: Link Step -->
|
||||
<template v-else>
|
||||
<div class="flex flex-col gap-4 px-4 py-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.inviteMemberDialog.linkStep.message') }}
|
||||
</p>
|
||||
<p class="m-0 text-sm font-medium text-base-foreground">
|
||||
{{ email }}
|
||||
</p>
|
||||
<div class="relative">
|
||||
<input
|
||||
:value="generatedLink"
|
||||
readonly
|
||||
class="w-full cursor-pointer rounded-lg border border-border-default bg-transparent px-3 py-2 pr-10 text-sm text-base-foreground focus:outline-none"
|
||||
@click="onSelectLink"
|
||||
/>
|
||||
<div
|
||||
class="absolute right-4 top-2 cursor-pointer"
|
||||
@click="onCopyLink"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
>
|
||||
<g clip-path="url(#clip0_2127_14348)">
|
||||
<path
|
||||
d="M2.66634 10.6666C1.93301 10.6666 1.33301 10.0666 1.33301 9.33325V2.66659C1.33301 1.93325 1.93301 1.33325 2.66634 1.33325H9.33301C10.0663 1.33325 10.6663 1.93325 10.6663 2.66659M6.66634 5.33325H13.333C14.0694 5.33325 14.6663 5.93021 14.6663 6.66658V13.3333C14.6663 14.0696 14.0694 14.6666 13.333 14.6666H6.66634C5.92996 14.6666 5.33301 14.0696 5.33301 13.3333V6.66658C5.33301 5.93021 5.92996 5.33325 6.66634 5.33325Z"
|
||||
stroke="white"
|
||||
stroke-width="1.3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2127_14348">
|
||||
<rect width="16" height="16" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer: Link Step -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-4">
|
||||
<Button variant="muted-textonly" @click="onCancel">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button variant="primary" size="lg" @click="onCopyLink">
|
||||
{{ $t('workspacePanel.inviteMemberDialog.linkStep.copyLink') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const email = ref('')
|
||||
const step = ref<'email' | 'link'>('email')
|
||||
const generatedLink = ref('')
|
||||
|
||||
const isValidEmail = computed(() => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
return emailRegex.test(email.value)
|
||||
})
|
||||
|
||||
function onCancel() {
|
||||
dialogStore.closeDialog({ key: 'invite-member' })
|
||||
}
|
||||
|
||||
async function onCreateLink() {
|
||||
if (!isValidEmail.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
generatedLink.value = await workspaceStore.createInviteLink(email.value)
|
||||
step.value = 'link'
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.inviteMemberDialog.linkCopyFailed'),
|
||||
detail: error instanceof Error ? error.message : undefined,
|
||||
life: 3000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onCopyLink() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(generatedLink.value)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('workspacePanel.inviteMemberDialog.linkCopied'),
|
||||
life: 2000
|
||||
})
|
||||
} catch {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.inviteMemberDialog.linkCopyFailed'),
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function onSelectLink(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
input.select()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[360px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
{{ $t('workspacePanel.leaveDialog.title') }}
|
||||
</h2>
|
||||
<button
|
||||
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="onCancel"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-4 py-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.leaveDialog.message') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-4">
|
||||
<Button variant="muted-textonly" @click="onCancel">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button variant="destructive" size="lg" :loading @click="onLeave">
|
||||
{{ $t('workspacePanel.leaveDialog.leave') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const dialogStore = useDialogStore()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const loading = ref(false)
|
||||
|
||||
function onCancel() {
|
||||
dialogStore.closeDialog({ key: 'leave-workspace' })
|
||||
}
|
||||
|
||||
async function onLeave() {
|
||||
loading.value = true
|
||||
try {
|
||||
// leaveWorkspace() handles switching to personal workspace internally and reloads
|
||||
await workspaceStore.leaveWorkspace()
|
||||
dialogStore.closeDialog({ key: 'leave-workspace' })
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
console.error('[LeaveWorkspaceDialog] Failed to leave workspace:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.toast.failedToLeaveWorkspace'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[360px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
{{ $t('workspacePanel.removeMemberDialog.title') }}
|
||||
</h2>
|
||||
<button
|
||||
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="onCancel"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-4 py-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.removeMemberDialog.message') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-4">
|
||||
<Button variant="muted-textonly" @click="onCancel">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button variant="destructive" size="lg" :loading @click="onRemove">
|
||||
{{ $t('workspacePanel.removeMemberDialog.remove') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { memberId } = defineProps<{
|
||||
memberId: string
|
||||
}>()
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
const loading = ref(false)
|
||||
|
||||
function onCancel() {
|
||||
dialogStore.closeDialog({ key: 'remove-member' })
|
||||
}
|
||||
|
||||
async function onRemove() {
|
||||
loading.value = true
|
||||
try {
|
||||
await workspaceStore.removeMember(memberId)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('workspacePanel.removeMemberDialog.success'),
|
||||
life: 2000
|
||||
})
|
||||
dialogStore.closeDialog({ key: 'remove-member' })
|
||||
} catch {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.removeMemberDialog.error'),
|
||||
life: 3000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[360px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
{{ $t('workspacePanel.revokeInviteDialog.title') }}
|
||||
</h2>
|
||||
<button
|
||||
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="onCancel"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-4 py-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.revokeInviteDialog.message') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-4">
|
||||
<Button variant="muted-textonly" @click="onCancel">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button variant="destructive" size="lg" :loading @click="onRevoke">
|
||||
{{ $t('workspacePanel.revokeInviteDialog.revoke') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { inviteId } = defineProps<{
|
||||
inviteId: string
|
||||
}>()
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
const loading = ref(false)
|
||||
|
||||
function onCancel() {
|
||||
dialogStore.closeDialog({ key: 'revoke-invite' })
|
||||
}
|
||||
|
||||
async function onRevoke() {
|
||||
loading.value = true
|
||||
try {
|
||||
await workspaceStore.revokeInvite(inviteId)
|
||||
dialogStore.closeDialog({ key: 'revoke-invite' })
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: error instanceof Error ? error.message : undefined,
|
||||
life: 3000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,300 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex min-w-[460px] flex-col rounded-2xl border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex py-8 items-center justify-between px-8">
|
||||
<h2 class="text-lg font-bold text-base-foreground m-0">
|
||||
{{
|
||||
isInsufficientCredits
|
||||
? $t('credits.topUp.addMoreCreditsToRun')
|
||||
: $t('credits.topUp.addMoreCredits')
|
||||
}}
|
||||
</h2>
|
||||
<button
|
||||
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
|
||||
@click="() => handleClose()"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-6" />
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
v-if="isInsufficientCredits"
|
||||
class="text-sm text-muted-foreground m-0 px-8"
|
||||
>
|
||||
{{ $t('credits.topUp.insufficientWorkflowMessage') }}
|
||||
</p>
|
||||
|
||||
<!-- Preset amount buttons -->
|
||||
<div class="px-8">
|
||||
<h3 class="m-0 text-sm font-normal text-muted-foreground">
|
||||
{{ $t('credits.topUp.selectAmount') }}
|
||||
</h3>
|
||||
<div class="flex gap-2 pt-3">
|
||||
<Button
|
||||
v-for="amount in PRESET_AMOUNTS"
|
||||
:key="amount"
|
||||
:autofocus="amount === 50"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:class="
|
||||
cn(
|
||||
'h-10 text-base font-medium w-full focus-visible:ring-secondary-foreground',
|
||||
selectedPreset === amount && 'bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
@click="handlePresetClick(amount)"
|
||||
>
|
||||
${{ amount }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Amount (USD) / Credits -->
|
||||
<div class="flex gap-2 px-8 pt-8">
|
||||
<!-- You Pay -->
|
||||
<div class="flex flex-1 flex-col gap-3">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{ $t('credits.topUp.youPay') }}
|
||||
</div>
|
||||
<FormattedNumberStepper
|
||||
:model-value="payAmount"
|
||||
:min="0"
|
||||
:max="MAX_AMOUNT"
|
||||
:step="getStepAmount"
|
||||
@update:model-value="handlePayAmountChange"
|
||||
@max-reached="showCeilingWarning = true"
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="shrink-0 text-base font-semibold text-base-foreground"
|
||||
>$</span
|
||||
>
|
||||
</template>
|
||||
</FormattedNumberStepper>
|
||||
</div>
|
||||
|
||||
<!-- You Get -->
|
||||
<div class="flex flex-1 flex-col gap-3">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{ $t('credits.topUp.youGet') }}
|
||||
</div>
|
||||
<FormattedNumberStepper
|
||||
v-model="creditsModel"
|
||||
:min="0"
|
||||
:max="usdToCredits(MAX_AMOUNT)"
|
||||
:step="getCreditsStepAmount"
|
||||
@max-reached="showCeilingWarning = true"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
|
||||
</template>
|
||||
</FormattedNumberStepper>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warnings -->
|
||||
|
||||
<p
|
||||
v-if="isBelowMin"
|
||||
class="text-sm text-red-500 m-0 px-8 pt-4 text-center flex items-center justify-center gap-1"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
{{
|
||||
$t('credits.topUp.minRequired', {
|
||||
credits: formatNumber(usdToCredits(MIN_AMOUNT))
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<p
|
||||
v-if="showCeilingWarning"
|
||||
class="text-sm text-gold-500 m-0 px-8 pt-4 text-center flex items-center justify-center gap-1"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
{{
|
||||
$t('credits.topUp.maxAllowed', {
|
||||
credits: formatNumber(usdToCredits(MAX_AMOUNT))
|
||||
})
|
||||
}}
|
||||
<span>{{ $t('credits.topUp.needMore') }}</span>
|
||||
<a
|
||||
href="https://www.comfy.org/cloud/enterprise"
|
||||
target="_blank"
|
||||
class="ml-1 text-inherit"
|
||||
>{{ $t('credits.topUp.contactUs') }}</a
|
||||
>
|
||||
</p>
|
||||
|
||||
<div class="pt-8 pb-8 flex flex-col gap-8 px-8">
|
||||
<Button
|
||||
:disabled="!isValidAmount || loading || isPolling"
|
||||
:loading="loading || isPolling"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
class="h-10 justify-center"
|
||||
@click="handleBuy"
|
||||
>
|
||||
{{ $t('subscription.addCredits') }}
|
||||
</Button>
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<a
|
||||
:href="pricingUrl"
|
||||
target="_blank"
|
||||
class="flex items-center gap-1 text-sm text-muted-foreground no-underline transition-colors hover:text-base-foreground"
|
||||
>
|
||||
{{ $t('credits.topUp.viewPricing') }}
|
||||
<i class="icon-[lucide--external-link] size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { captureException } from '@sentry/vue'
|
||||
|
||||
import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { isInsufficientCredits = false } = defineProps<{
|
||||
isInsufficientCredits?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
const dialogService = useDialogService()
|
||||
const telemetry = useTelemetry()
|
||||
const toast = useToast()
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const { fetchBalance } = useBillingContext()
|
||||
|
||||
const billingOperationStore = useBillingOperationStore()
|
||||
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
|
||||
|
||||
// Constants
|
||||
const PRESET_AMOUNTS = [10, 25, 50, 100]
|
||||
const MIN_AMOUNT = 5
|
||||
const MAX_AMOUNT = 10000
|
||||
|
||||
// State
|
||||
const selectedPreset = ref<number | null>(50)
|
||||
const payAmount = ref(50)
|
||||
const showCeilingWarning = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
// Computed
|
||||
const pricingUrl = computed(() =>
|
||||
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true })
|
||||
)
|
||||
|
||||
const creditsModel = computed({
|
||||
get: () => usdToCredits(payAmount.value),
|
||||
set: (newCredits: number) => {
|
||||
payAmount.value = Math.round(creditsToUsd(newCredits))
|
||||
selectedPreset.value = null
|
||||
}
|
||||
})
|
||||
|
||||
const isValidAmount = computed(
|
||||
() => payAmount.value >= MIN_AMOUNT && payAmount.value <= MAX_AMOUNT
|
||||
)
|
||||
|
||||
const isBelowMin = computed(() => payAmount.value < MIN_AMOUNT)
|
||||
|
||||
// Utility functions
|
||||
function formatNumber(num: number): string {
|
||||
return num.toLocaleString('en-US')
|
||||
}
|
||||
|
||||
// Step amount functions
|
||||
function getStepAmount(currentAmount: number): number {
|
||||
if (currentAmount < 100) return 5
|
||||
if (currentAmount < 1000) return 50
|
||||
return 100
|
||||
}
|
||||
|
||||
function getCreditsStepAmount(currentCredits: number): number {
|
||||
const usdAmount = creditsToUsd(currentCredits)
|
||||
return usdToCredits(getStepAmount(usdAmount))
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
function handlePayAmountChange(value: number) {
|
||||
payAmount.value = value
|
||||
selectedPreset.value = null
|
||||
showCeilingWarning.value = false
|
||||
}
|
||||
|
||||
function handlePresetClick(amount: number) {
|
||||
showCeilingWarning.value = false
|
||||
payAmount.value = amount
|
||||
selectedPreset.value = amount
|
||||
}
|
||||
|
||||
function handleClose(clearTracking = true) {
|
||||
if (clearTracking) {
|
||||
clearTopupTracking()
|
||||
}
|
||||
dialogStore.closeDialog({ key: 'top-up-credits' })
|
||||
}
|
||||
|
||||
async function handleBuy() {
|
||||
if (loading.value || !isValidAmount.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
telemetry?.trackApiCreditTopupButtonPurchaseClicked(payAmount.value)
|
||||
|
||||
const amountCents = payAmount.value * 100
|
||||
const response = await workspaceApi.createTopup(amountCents)
|
||||
|
||||
if (response.status === 'completed') {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('credits.topUp.purchaseSuccess'),
|
||||
life: 5000
|
||||
})
|
||||
void fetchBalance()
|
||||
handleClose(false)
|
||||
dialogService.showSettingsDialog('workspace')
|
||||
} else if (response.status === 'pending') {
|
||||
billingOperationStore.startOperation(response.billing_op_id, 'topup')
|
||||
} else {
|
||||
captureException(
|
||||
new Error(`Unexpected topup response status: ${response.status}`)
|
||||
)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('credits.topUp.purchaseError'),
|
||||
detail: t('credits.topUp.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
captureException(error)
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : t('credits.topUp.unknownError')
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('credits.topUp.purchaseError'),
|
||||
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage }),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
11
src/platform/workspace/components/WorkspacePanel.vue
Normal file
11
src/platform/workspace/components/WorkspacePanel.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<TabPanel value="Workspace" class="h-full">
|
||||
<WorkspacePanelContent />
|
||||
</TabPanel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
|
||||
import WorkspacePanelContent from '@/platform/workspace/components/WorkspacePanelContent.vue'
|
||||
</script>
|
||||
238
src/platform/workspace/components/WorkspacePanelContent.vue
Normal file
238
src/platform/workspace/components/WorkspacePanelContent.vue
Normal file
@@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<div class="flex h-full w-full flex-col">
|
||||
<div class="pb-8 flex items-center gap-4">
|
||||
<WorkspaceProfilePic
|
||||
class="size-12 !text-3xl"
|
||||
:workspace-name="workspaceName"
|
||||
/>
|
||||
<h1 class="text-3xl text-base-foreground">
|
||||
{{ workspaceName }}
|
||||
</h1>
|
||||
</div>
|
||||
<Tabs unstyled :value="activeTab" @update:value="setActiveTab">
|
||||
<div class="flex w-full items-center">
|
||||
<TabList unstyled class="flex w-full gap-2">
|
||||
<Tab
|
||||
value="plan"
|
||||
:class="
|
||||
cn(
|
||||
buttonVariants({
|
||||
variant: activeTab === 'plan' ? 'secondary' : 'textonly',
|
||||
size: 'md'
|
||||
}),
|
||||
activeTab === 'plan' && 'text-base-foreground no-underline'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ $t('workspacePanel.tabs.planCredits') }}
|
||||
</Tab>
|
||||
<Tab
|
||||
value="members"
|
||||
:class="
|
||||
cn(
|
||||
buttonVariants({
|
||||
variant: activeTab === 'members' ? 'secondary' : 'textonly',
|
||||
size: 'md'
|
||||
}),
|
||||
activeTab === 'members' && 'text-base-foreground no-underline',
|
||||
'ml-2'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
$t('workspacePanel.tabs.membersCount', {
|
||||
count: isInPersonalWorkspace ? 1 : members.length
|
||||
})
|
||||
}}
|
||||
</Tab>
|
||||
</TabList>
|
||||
<Button
|
||||
v-if="permissions.canInviteMembers"
|
||||
v-tooltip="
|
||||
inviteTooltip
|
||||
? { value: inviteTooltip, showDelay: 0 }
|
||||
: { value: $t('workspacePanel.inviteMember'), showDelay: 300 }
|
||||
"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:disabled="isInviteLimitReached"
|
||||
:class="isInviteLimitReached && 'opacity-50 cursor-not-allowed'"
|
||||
:aria-label="$t('workspacePanel.inviteMember')"
|
||||
@click="handleInviteMember"
|
||||
>
|
||||
{{ $t('workspacePanel.invite') }}
|
||||
<i class="pi pi-plus ml-1 text-sm" />
|
||||
</Button>
|
||||
<template v-if="permissions.canAccessWorkspaceMenu">
|
||||
<Button
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
class="ml-2"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
@click="menu?.toggle($event)"
|
||||
>
|
||||
<i class="pi pi-ellipsis-h" />
|
||||
</Button>
|
||||
<Menu ref="menu" :model="menuItems" :popup="true">
|
||||
<template #item="{ item }">
|
||||
<div
|
||||
v-tooltip="
|
||||
item.disabled && deleteTooltip
|
||||
? { value: deleteTooltip, showDelay: 0 }
|
||||
: null
|
||||
"
|
||||
:class="[
|
||||
'flex items-center gap-2 px-3 py-2',
|
||||
item.class,
|
||||
item.disabled ? 'pointer-events-auto' : 'cursor-pointer'
|
||||
]"
|
||||
@click="
|
||||
item.command?.({
|
||||
originalEvent: $event,
|
||||
item
|
||||
})
|
||||
"
|
||||
>
|
||||
<i :class="item.icon" />
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Menu>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<TabPanels unstyled>
|
||||
<TabPanel value="plan">
|
||||
<SubscriptionPanelContentWorkspace />
|
||||
</TabPanel>
|
||||
<TabPanel value="members">
|
||||
<MembersPanelContent :key="workspaceRole" />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Menu from 'primevue/menu'
|
||||
import Tab from 'primevue/tab'
|
||||
import TabList from 'primevue/tablist'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import TabPanels from 'primevue/tabpanels'
|
||||
import Tabs from 'primevue/tabs'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import MembersPanelContent from '@/components/dialog/content/setting/MembersPanelContent.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { buttonVariants } from '@/components/ui/button/button.variants'
|
||||
import SubscriptionPanelContentWorkspace from '@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
const { defaultTab = 'plan' } = defineProps<{
|
||||
defaultTab?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
showLeaveWorkspaceDialog,
|
||||
showDeleteWorkspaceDialog,
|
||||
showInviteMemberDialog,
|
||||
showEditWorkspaceDialog
|
||||
} = useDialogService()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const {
|
||||
workspaceName,
|
||||
members,
|
||||
isInviteLimitReached,
|
||||
isWorkspaceSubscribed,
|
||||
isInPersonalWorkspace
|
||||
} = storeToRefs(workspaceStore)
|
||||
const { fetchMembers, fetchPendingInvites } = workspaceStore
|
||||
const { activeTab, setActiveTab, workspaceRole, permissions, uiConfig } =
|
||||
useWorkspaceUI()
|
||||
|
||||
const menu = ref<InstanceType<typeof Menu> | null>(null)
|
||||
|
||||
function handleLeaveWorkspace() {
|
||||
showLeaveWorkspaceDialog()
|
||||
}
|
||||
|
||||
function handleDeleteWorkspace() {
|
||||
showDeleteWorkspaceDialog()
|
||||
}
|
||||
|
||||
function handleEditWorkspace() {
|
||||
showEditWorkspaceDialog()
|
||||
}
|
||||
|
||||
// Disable delete when workspace has an active subscription (to prevent accidental deletion)
|
||||
// Use workspace's own subscription status, not the global isActiveSubscription
|
||||
const isDeleteDisabled = computed(
|
||||
() =>
|
||||
uiConfig.value.workspaceMenuAction === 'delete' &&
|
||||
isWorkspaceSubscribed.value
|
||||
)
|
||||
|
||||
const deleteTooltip = computed(() => {
|
||||
if (!isDeleteDisabled.value) return null
|
||||
const tooltipKey = uiConfig.value.workspaceMenuDisabledTooltip
|
||||
return tooltipKey ? t(tooltipKey) : null
|
||||
})
|
||||
|
||||
const inviteTooltip = computed(() => {
|
||||
if (!isInviteLimitReached.value) return null
|
||||
return t('workspacePanel.inviteLimitReached')
|
||||
})
|
||||
|
||||
function handleInviteMember() {
|
||||
if (isInviteLimitReached.value) return
|
||||
showInviteMemberDialog()
|
||||
}
|
||||
|
||||
const menuItems = computed(() => {
|
||||
const items = []
|
||||
|
||||
// Add edit option for owners
|
||||
if (uiConfig.value.showEditWorkspaceMenuItem) {
|
||||
items.push({
|
||||
label: t('workspacePanel.menu.editWorkspace'),
|
||||
icon: 'pi pi-pencil',
|
||||
command: handleEditWorkspace
|
||||
})
|
||||
}
|
||||
|
||||
const action = uiConfig.value.workspaceMenuAction
|
||||
if (action === 'delete') {
|
||||
items.push({
|
||||
label: t('workspacePanel.menu.deleteWorkspace'),
|
||||
icon: 'pi pi-trash',
|
||||
class: isDeleteDisabled.value
|
||||
? 'text-danger/50 cursor-not-allowed'
|
||||
: 'text-danger',
|
||||
disabled: isDeleteDisabled.value,
|
||||
command: isDeleteDisabled.value ? undefined : handleDeleteWorkspace
|
||||
})
|
||||
} else if (action === 'leave') {
|
||||
items.push({
|
||||
label: t('workspacePanel.menu.leaveWorkspace'),
|
||||
icon: 'pi pi-sign-out',
|
||||
command: handleLeaveWorkspace
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
setActiveTab(defaultTab)
|
||||
fetchMembers()
|
||||
fetchPendingInvites()
|
||||
})
|
||||
</script>
|
||||
19
src/platform/workspace/components/WorkspaceSidebarItem.vue
Normal file
19
src/platform/workspace/components/WorkspaceSidebarItem.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<WorkspaceProfilePic
|
||||
class="size-6 text-xs"
|
||||
:workspace-name="workspaceName"
|
||||
/>
|
||||
|
||||
<span>{{ workspaceName }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
const { workspaceName } = storeToRefs(useTeamWorkspaceStore())
|
||||
</script>
|
||||
311
src/platform/workspace/composables/useWorkspaceBilling.ts
Normal file
311
src/platform/workspace/composables/useWorkspaceBilling.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
import { computed, onBeforeUnmount, ref, shallowRef } from 'vue'
|
||||
|
||||
import { useBillingPlans } from '@/platform/cloud/subscription/composables/useBillingPlans'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type {
|
||||
BillingBalanceResponse,
|
||||
BillingStatusResponse,
|
||||
PreviewSubscribeResponse,
|
||||
SubscribeResponse
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
import type {
|
||||
BalanceInfo,
|
||||
BillingActions,
|
||||
BillingState,
|
||||
SubscriptionInfo
|
||||
} from '@/composables/billing/types'
|
||||
|
||||
/**
|
||||
* Adapter for workspace-scoped billing via /billing/* endpoints.
|
||||
* Used for team workspaces.
|
||||
* @internal - Use useBillingContext() instead of importing directly.
|
||||
*/
|
||||
export function useWorkspaceBilling(): BillingState & BillingActions {
|
||||
const billingPlans = useBillingPlans()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
|
||||
const isInitialized = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const statusData = shallowRef<BillingStatusResponse | null>(null)
|
||||
const balanceData = shallowRef<BillingBalanceResponse | null>(null)
|
||||
|
||||
const isActiveSubscription = computed(
|
||||
() => statusData.value?.is_active ?? false
|
||||
)
|
||||
|
||||
const subscription = computed<SubscriptionInfo | null>(() => {
|
||||
const status = statusData.value
|
||||
if (!status) return null
|
||||
|
||||
return {
|
||||
isActive: status.is_active,
|
||||
tier: status.subscription_tier ?? null,
|
||||
duration: status.subscription_duration ?? null,
|
||||
planSlug: status.plan_slug ?? null,
|
||||
renewalDate: null, // Workspace billing uses cancel_at for end date
|
||||
endDate: status.cancel_at ?? null,
|
||||
isCancelled: status.subscription_status === 'canceled',
|
||||
hasFunds: status.has_funds
|
||||
}
|
||||
})
|
||||
|
||||
const balance = computed<BalanceInfo | null>(() => {
|
||||
const data = balanceData.value
|
||||
if (!data) return null
|
||||
|
||||
return {
|
||||
amountMicros: data.amount_micros,
|
||||
currency: data.currency,
|
||||
effectiveBalanceMicros: data.effective_balance_micros,
|
||||
prepaidBalanceMicros: data.prepaid_balance_micros,
|
||||
cloudCreditBalanceMicros: data.cloud_credit_balance_micros
|
||||
}
|
||||
})
|
||||
|
||||
const plans = computed(() => billingPlans.plans.value)
|
||||
const currentPlanSlug = computed(
|
||||
() => statusData.value?.plan_slug ?? billingPlans.currentPlanSlug.value
|
||||
)
|
||||
|
||||
const pendingCancelOpId = ref<string | null>(null)
|
||||
let cancelPollTimeout: number | null = null
|
||||
|
||||
const stopCancelPolling = () => {
|
||||
if (cancelPollTimeout !== null) {
|
||||
window.clearTimeout(cancelPollTimeout)
|
||||
cancelPollTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
async function pollCancelStatus(opId: string): Promise<void> {
|
||||
stopCancelPolling()
|
||||
|
||||
const maxAttempts = 30
|
||||
let attempt = 0
|
||||
const poll = async () => {
|
||||
if (pendingCancelOpId.value !== opId) return
|
||||
|
||||
try {
|
||||
const response = await workspaceApi.getBillingOpStatus(opId)
|
||||
if (response.status === 'succeeded') {
|
||||
pendingCancelOpId.value = null
|
||||
stopCancelPolling()
|
||||
await fetchStatus()
|
||||
workspaceStore.updateActiveWorkspace({
|
||||
isSubscribed: false
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (response.status === 'failed') {
|
||||
pendingCancelOpId.value = null
|
||||
stopCancelPolling()
|
||||
throw new Error(
|
||||
response.error_message ?? 'Failed to cancel subscription'
|
||||
)
|
||||
}
|
||||
|
||||
attempt += 1
|
||||
if (attempt >= maxAttempts) {
|
||||
pendingCancelOpId.value = null
|
||||
stopCancelPolling()
|
||||
await fetchStatus()
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
pendingCancelOpId.value = null
|
||||
stopCancelPolling()
|
||||
throw err
|
||||
}
|
||||
|
||||
cancelPollTimeout = window.setTimeout(
|
||||
() => {
|
||||
void poll()
|
||||
},
|
||||
Math.min(1000 * 2 ** attempt, 5000)
|
||||
)
|
||||
}
|
||||
|
||||
await poll()
|
||||
}
|
||||
|
||||
async function initialize(): Promise<void> {
|
||||
if (isInitialized.value) return
|
||||
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await Promise.all([fetchStatus(), fetchBalance(), fetchPlans()])
|
||||
isInitialized.value = true
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to initialize billing'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStatus(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
statusData.value = await workspaceApi.getBillingStatus()
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to fetch billing status'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBalance(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
balanceData.value = await workspaceApi.getBillingBalance()
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to fetch balance'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribe(
|
||||
planSlug: string,
|
||||
returnUrl?: string,
|
||||
cancelUrl?: string
|
||||
): Promise<SubscribeResponse> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await workspaceApi.subscribe(
|
||||
planSlug,
|
||||
returnUrl,
|
||||
cancelUrl
|
||||
)
|
||||
|
||||
// Refresh status and balance after subscription
|
||||
await Promise.all([fetchStatus(), fetchBalance()])
|
||||
|
||||
return response
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to subscribe'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function previewSubscribe(
|
||||
planSlug: string
|
||||
): Promise<PreviewSubscribeResponse | null> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
return await workspaceApi.previewSubscribe(planSlug)
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to preview subscription'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function manageSubscription(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const returnUrl = window.location.href
|
||||
const response = await workspaceApi.getPaymentPortalUrl(returnUrl)
|
||||
if (response.url) {
|
||||
window.open(response.url, '_blank')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to open billing portal'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelSubscription(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await workspaceApi.cancelSubscription()
|
||||
pendingCancelOpId.value = response.billing_op_id
|
||||
await pollCancelStatus(response.billing_op_id)
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to cancel subscription'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPlans(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await billingPlans.fetchPlans()
|
||||
if (billingPlans.error.value) {
|
||||
error.value = billingPlans.error.value
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const subscriptionDialog = useSubscriptionDialog()
|
||||
|
||||
async function requireActiveSubscription(): Promise<void> {
|
||||
await fetchStatus()
|
||||
if (!isActiveSubscription.value) {
|
||||
subscriptionDialog.show()
|
||||
}
|
||||
}
|
||||
|
||||
function showSubscriptionDialog(): void {
|
||||
subscriptionDialog.show()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopCancelPolling()
|
||||
})
|
||||
|
||||
return {
|
||||
// State
|
||||
isInitialized,
|
||||
subscription,
|
||||
balance,
|
||||
plans,
|
||||
currentPlanSlug,
|
||||
isLoading,
|
||||
error,
|
||||
isActiveSubscription,
|
||||
|
||||
// Actions
|
||||
initialize,
|
||||
fetchStatus,
|
||||
fetchBalance,
|
||||
subscribe,
|
||||
previewSubscribe,
|
||||
manageSubscription,
|
||||
cancelSubscription,
|
||||
fetchPlans,
|
||||
requireActiveSubscription,
|
||||
showSubscriptionDialog
|
||||
}
|
||||
}
|
||||
504
src/platform/workspace/stores/billingOperationStore.test.ts
Normal file
504
src/platform/workspace/stores/billingOperationStore.test.ts
Normal file
@@ -0,0 +1,504 @@
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi, afterEach } from 'vitest'
|
||||
|
||||
import type { BillingOpStatusResponse } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
const mockFetchStatus = vi.fn()
|
||||
const mockFetchBalance = vi.fn()
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
fetchStatus: mockFetchStatus,
|
||||
fetchBalance: mockFetchBalance
|
||||
})
|
||||
}))
|
||||
|
||||
const mockToastAdd = vi.fn()
|
||||
const mockToastRemove = vi.fn()
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({
|
||||
add: mockToastAdd,
|
||||
remove: mockToastRemove
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
|
||||
workspaceApi: {
|
||||
getBillingOpStatus: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
showSettingsDialog: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({
|
||||
closeDialog: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { useBillingOperationStore } from './billingOperationStore'
|
||||
|
||||
describe('billingOperationStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('startOperation', () => {
|
||||
it('creates a pending operation', () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'pending',
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
store.startOperation('op-1', 'subscription')
|
||||
|
||||
expect(store.operations.size).toBe(1)
|
||||
const operation = store.getOperation('op-1')
|
||||
expect(operation).toBeDefined()
|
||||
expect(operation?.status).toBe('pending')
|
||||
expect(operation?.type).toBe('subscription')
|
||||
expect(store.hasPendingOperations).toBe(true)
|
||||
})
|
||||
|
||||
it('does not create duplicate operations', () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'pending',
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
store.startOperation('op-1', 'subscription')
|
||||
store.startOperation('op-1', 'topup')
|
||||
|
||||
expect(store.operations.size).toBe(1)
|
||||
expect(store.getOperation('op-1')?.type).toBe('subscription')
|
||||
})
|
||||
|
||||
it('shows immediate processing toast for subscription operations', () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'pending',
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
store.startOperation('op-1', 'subscription')
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'info',
|
||||
summary: 'billingOperation.subscriptionProcessing',
|
||||
group: 'billing-operation'
|
||||
})
|
||||
})
|
||||
|
||||
it('shows immediate processing toast for topup operations', () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'pending',
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
store.startOperation('op-1', 'topup')
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'info',
|
||||
summary: 'billingOperation.topupProcessing',
|
||||
group: 'billing-operation'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('polling success', () => {
|
||||
it('updates status and shows toast on success', async () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'succeeded',
|
||||
started_at: new Date().toISOString(),
|
||||
completed_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
store.startOperation('op-1', 'subscription')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
const operation = store.getOperation('op-1')
|
||||
expect(operation?.status).toBe('succeeded')
|
||||
expect(store.hasPendingOperations).toBe(false)
|
||||
|
||||
expect(mockFetchStatus).toHaveBeenCalled()
|
||||
expect(mockFetchBalance).toHaveBeenCalled()
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'success',
|
||||
summary: 'billingOperation.subscriptionSuccess',
|
||||
life: 5000
|
||||
})
|
||||
})
|
||||
|
||||
it('shows topup success message for topup operations', async () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'succeeded',
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
store.startOperation('op-1', 'topup')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'success',
|
||||
summary: 'billingOperation.topupSuccess',
|
||||
life: 5000
|
||||
})
|
||||
})
|
||||
|
||||
it('removes the received toast when operation succeeds', async () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'succeeded',
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
store.startOperation('op-1', 'subscription')
|
||||
|
||||
const receivedToast = mockToastAdd.mock.calls[0][0]
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(mockToastRemove).toHaveBeenCalledWith(receivedToast)
|
||||
})
|
||||
})
|
||||
|
||||
describe('polling failure', () => {
|
||||
it('updates status and shows error toast on failure', async () => {
|
||||
const errorMessage = 'Payment declined'
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'failed',
|
||||
error_message: errorMessage,
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
store.startOperation('op-1', 'subscription')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
const operation = store.getOperation('op-1')
|
||||
expect(operation?.status).toBe('failed')
|
||||
expect(operation?.errorMessage).toBe(errorMessage)
|
||||
expect(store.hasPendingOperations).toBe(false)
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'billingOperation.subscriptionFailed',
|
||||
detail: errorMessage,
|
||||
life: 5000
|
||||
})
|
||||
})
|
||||
|
||||
it('uses default message when no error_message in response', async () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'failed',
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
store.startOperation('op-1', 'topup')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'billingOperation.topupFailed',
|
||||
detail: undefined,
|
||||
life: 5000
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('polling timeout', () => {
|
||||
it('times out after 2 minutes and shows error toast', async () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'pending',
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
store.startOperation('op-1', 'subscription')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(121_000)
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
const operation = store.getOperation('op-1')
|
||||
expect(operation?.status).toBe('timeout')
|
||||
expect(store.hasPendingOperations).toBe(false)
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'billingOperation.subscriptionTimeout',
|
||||
life: 5000
|
||||
})
|
||||
})
|
||||
|
||||
it('shows topup timeout message for topup operations', async () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'pending',
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
store.startOperation('op-1', 'topup')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(121_000)
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'billingOperation.topupTimeout',
|
||||
life: 5000
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('exponential backoff', () => {
|
||||
it('uses exponential backoff for polling intervals', async () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'pending',
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
store.startOperation('op-1', 'subscription')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(workspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1500)
|
||||
expect(workspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(2)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2250)
|
||||
expect(workspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('caps polling interval at 8 seconds', async () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'pending',
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
store.startOperation('op-1', 'subscription')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
|
||||
const callCountBefore = vi.mocked(workspaceApi.getBillingOpStatus).mock
|
||||
.calls.length
|
||||
|
||||
await vi.advanceTimersByTimeAsync(8000)
|
||||
|
||||
expect(
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mock.calls.length
|
||||
).toBeGreaterThan(callCountBefore)
|
||||
})
|
||||
})
|
||||
|
||||
describe('network errors', () => {
|
||||
it('continues polling on network errors', async () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus)
|
||||
.mockRejectedValueOnce(new Error('Network error'))
|
||||
.mockRejectedValueOnce(new Error('Network error'))
|
||||
.mockResolvedValueOnce({
|
||||
id: 'op-1',
|
||||
status: 'succeeded',
|
||||
started_at: new Date().toISOString()
|
||||
} satisfies BillingOpStatusResponse)
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
store.startOperation('op-1', 'subscription')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(store.getOperation('op-1')?.status).toBe('pending')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1500)
|
||||
expect(store.getOperation('op-1')?.status).toBe('pending')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2250)
|
||||
expect(store.getOperation('op-1')?.status).toBe('succeeded')
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearOperation', () => {
|
||||
it('removes operation from the store', async () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'succeeded',
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
store.startOperation('op-1', 'subscription')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(store.operations.size).toBe(1)
|
||||
|
||||
store.clearOperation('op-1')
|
||||
|
||||
expect(store.operations.size).toBe(0)
|
||||
expect(store.getOperation('op-1')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('multiple operations', () => {
|
||||
it('can track multiple operations concurrently', async () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockImplementation(
|
||||
async (opId: string) => ({
|
||||
id: opId,
|
||||
status: 'pending' as const,
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
)
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
store.startOperation('op-1', 'subscription')
|
||||
store.startOperation('op-2', 'topup')
|
||||
|
||||
expect(store.operations.size).toBe(2)
|
||||
expect(store.hasPendingOperations).toBe(true)
|
||||
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockImplementation(
|
||||
async (opId: string) => ({
|
||||
id: opId,
|
||||
status:
|
||||
opId === 'op-1' ? ('succeeded' as const) : ('pending' as const),
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1500)
|
||||
|
||||
expect(store.getOperation('op-1')?.status).toBe('succeeded')
|
||||
expect(store.getOperation('op-2')?.status).toBe('pending')
|
||||
expect(store.hasPendingOperations).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSettingUp', () => {
|
||||
it('returns true when there is a pending subscription operation', () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'pending',
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
store.startOperation('op-1', 'subscription')
|
||||
|
||||
expect(store.isSettingUp).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when there is no pending subscription operation', async () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'succeeded',
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
store.startOperation('op-1', 'subscription')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(store.isSettingUp).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when only topup operations are pending', () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'pending',
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
store.startOperation('op-1', 'topup')
|
||||
|
||||
expect(store.isSettingUp).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAddingCredits', () => {
|
||||
it('returns true when there is a pending topup operation', () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'pending',
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
store.startOperation('op-1', 'topup')
|
||||
|
||||
expect(store.isAddingCredits).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when there is no pending topup operation', async () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'succeeded',
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
store.startOperation('op-1', 'topup')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(store.isAddingCredits).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when only subscription operations are pending', () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'pending',
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
store.startOperation('op-1', 'subscription')
|
||||
|
||||
expect(store.isAddingCredits).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
244
src/platform/workspace/stores/billingOperationStore.ts
Normal file
244
src/platform/workspace/stores/billingOperationStore.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import type { ToastMessageOptions } from 'primevue/toast'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { t } from '@/i18n'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const INITIAL_INTERVAL_MS = 1000
|
||||
const MAX_INTERVAL_MS = 8000
|
||||
const BACKOFF_MULTIPLIER = 1.5
|
||||
const TIMEOUT_MS = 120_000 // 2 minutes
|
||||
|
||||
type OperationType = 'subscription' | 'topup'
|
||||
type OperationStatus = 'pending' | 'succeeded' | 'failed' | 'timeout'
|
||||
|
||||
interface BillingOperation {
|
||||
opId: string
|
||||
type: OperationType
|
||||
status: OperationStatus
|
||||
errorMessage: string | null
|
||||
startedAt: number
|
||||
}
|
||||
|
||||
export const useBillingOperationStore = defineStore('billingOperation', () => {
|
||||
const operations = ref<Map<string, BillingOperation>>(new Map())
|
||||
const timeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
const intervals = new Map<string, number>()
|
||||
const receivedToasts = new Map<string, ToastMessageOptions>()
|
||||
|
||||
const hasPendingOperations = computed(() =>
|
||||
[...operations.value.values()].some((op) => op.status === 'pending')
|
||||
)
|
||||
|
||||
const isSettingUp = computed(() =>
|
||||
[...operations.value.values()].some(
|
||||
(op) => op.status === 'pending' && op.type === 'subscription'
|
||||
)
|
||||
)
|
||||
|
||||
const isAddingCredits = computed(() =>
|
||||
[...operations.value.values()].some(
|
||||
(op) => op.status === 'pending' && op.type === 'topup'
|
||||
)
|
||||
)
|
||||
|
||||
function getOperation(opId: string) {
|
||||
return operations.value.get(opId)
|
||||
}
|
||||
|
||||
function startOperation(opId: string, type: OperationType) {
|
||||
if (operations.value.has(opId)) return
|
||||
|
||||
const operation: BillingOperation = {
|
||||
opId,
|
||||
type,
|
||||
status: 'pending',
|
||||
errorMessage: null,
|
||||
startedAt: Date.now()
|
||||
}
|
||||
|
||||
operations.value = new Map(operations.value).set(opId, operation)
|
||||
intervals.set(opId, INITIAL_INTERVAL_MS)
|
||||
|
||||
// Show immediate feedback toast (persists until operation completes)
|
||||
const messageKey =
|
||||
type === 'subscription'
|
||||
? 'billingOperation.subscriptionProcessing'
|
||||
: 'billingOperation.topupProcessing'
|
||||
|
||||
const toastMessage: ToastMessageOptions = {
|
||||
severity: 'info',
|
||||
summary: t(messageKey),
|
||||
group: 'billing-operation'
|
||||
}
|
||||
receivedToasts.set(opId, toastMessage)
|
||||
useToastStore().add(toastMessage)
|
||||
|
||||
void poll(opId)
|
||||
}
|
||||
|
||||
async function poll(opId: string) {
|
||||
const operation = operations.value.get(opId)
|
||||
if (!operation || operation.status !== 'pending') return
|
||||
|
||||
if (Date.now() - operation.startedAt > TIMEOUT_MS) {
|
||||
handleTimeout(opId)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await workspaceApi.getBillingOpStatus(opId)
|
||||
|
||||
if (response.status === 'succeeded') {
|
||||
await handleSuccess(opId)
|
||||
return
|
||||
}
|
||||
|
||||
if (response.status === 'failed') {
|
||||
handleFailure(opId, response.error_message ?? null)
|
||||
return
|
||||
}
|
||||
|
||||
scheduleNextPoll(opId)
|
||||
} catch {
|
||||
if (Date.now() - operation.startedAt > TIMEOUT_MS) {
|
||||
handleTimeout(opId)
|
||||
return
|
||||
}
|
||||
scheduleNextPoll(opId)
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleNextPoll(opId: string) {
|
||||
const currentInterval = intervals.get(opId) ?? INITIAL_INTERVAL_MS
|
||||
const nextInterval = Math.min(
|
||||
currentInterval * BACKOFF_MULTIPLIER,
|
||||
MAX_INTERVAL_MS
|
||||
)
|
||||
intervals.set(opId, nextInterval)
|
||||
|
||||
const timeoutId = setTimeout(() => void poll(opId), nextInterval)
|
||||
timeouts.set(opId, timeoutId)
|
||||
}
|
||||
|
||||
async function handleSuccess(opId: string) {
|
||||
const operation = operations.value.get(opId)
|
||||
if (!operation) return
|
||||
|
||||
updateOperationStatus(opId, 'succeeded', null)
|
||||
cleanup(opId)
|
||||
|
||||
const billingContext = useBillingContext()
|
||||
await Promise.all([
|
||||
billingContext.fetchStatus(),
|
||||
billingContext.fetchBalance()
|
||||
])
|
||||
|
||||
// Close any open billing dialogs and show settings
|
||||
const dialogStore = useDialogStore()
|
||||
dialogStore.closeDialog({ key: 'subscription-required' })
|
||||
dialogStore.closeDialog({ key: 'top-up-credits' })
|
||||
void useDialogService().showSettingsDialog('workspace')
|
||||
|
||||
const toastStore = useToastStore()
|
||||
const messageKey =
|
||||
operation.type === 'subscription'
|
||||
? 'billingOperation.subscriptionSuccess'
|
||||
: 'billingOperation.topupSuccess'
|
||||
|
||||
toastStore.add({
|
||||
severity: 'success',
|
||||
summary: t(messageKey),
|
||||
life: 5000
|
||||
})
|
||||
}
|
||||
|
||||
function handleFailure(opId: string, errorMessage: string | null) {
|
||||
const operation = operations.value.get(opId)
|
||||
if (!operation) return
|
||||
|
||||
const defaultMessage =
|
||||
operation.type === 'subscription'
|
||||
? t('billingOperation.subscriptionFailed')
|
||||
: t('billingOperation.topupFailed')
|
||||
|
||||
updateOperationStatus(opId, 'failed', errorMessage ?? defaultMessage)
|
||||
cleanup(opId)
|
||||
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: defaultMessage,
|
||||
detail: errorMessage ?? undefined,
|
||||
life: 5000
|
||||
})
|
||||
}
|
||||
|
||||
function handleTimeout(opId: string) {
|
||||
const operation = operations.value.get(opId)
|
||||
if (!operation) return
|
||||
|
||||
const message =
|
||||
operation.type === 'subscription'
|
||||
? t('billingOperation.subscriptionTimeout')
|
||||
: t('billingOperation.topupTimeout')
|
||||
|
||||
updateOperationStatus(opId, 'timeout', message)
|
||||
cleanup(opId)
|
||||
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: message,
|
||||
life: 5000
|
||||
})
|
||||
}
|
||||
|
||||
function updateOperationStatus(
|
||||
opId: string,
|
||||
status: OperationStatus,
|
||||
errorMessage: string | null
|
||||
) {
|
||||
const operation = operations.value.get(opId)
|
||||
if (!operation) return
|
||||
|
||||
const updated = { ...operation, status, errorMessage }
|
||||
operations.value = new Map(operations.value).set(opId, updated)
|
||||
}
|
||||
|
||||
function cleanup(opId: string) {
|
||||
const timeoutId = timeouts.get(opId)
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
timeouts.delete(opId)
|
||||
}
|
||||
intervals.delete(opId)
|
||||
|
||||
// Remove the "received" toast
|
||||
const receivedToast = receivedToasts.get(opId)
|
||||
if (receivedToast) {
|
||||
useToastStore().remove(receivedToast)
|
||||
receivedToasts.delete(opId)
|
||||
}
|
||||
}
|
||||
|
||||
function clearOperation(opId: string) {
|
||||
cleanup(opId)
|
||||
const newMap = new Map(operations.value)
|
||||
newMap.delete(opId)
|
||||
operations.value = newMap
|
||||
}
|
||||
|
||||
return {
|
||||
operations,
|
||||
hasPendingOperations,
|
||||
isSettingUp,
|
||||
isAddingCredits,
|
||||
getOperation,
|
||||
startOperation,
|
||||
clearOperation
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user