Files
ComfyUI_frontend/src/platform/workspace/api/workspaceApi.ts
Christian Byrne 62979e3818 refactor: rename firebaseAuthStore to authStore with shared test fixtures (#10483)
## Summary

Rename `useFirebaseAuthStore` → `useAuthStore` and
`FirebaseAuthStoreError` → `AuthStoreError`. Introduce shared mock
factory (`authStoreMock.ts`) to replace 16 independent bespoke mocks.

## Changes

- **What**: Mechanical rename of store, composable, class, and store ID
(`firebaseAuth` → `auth`). Created
`src/stores/__tests__/authStoreMock.ts` — a shared mock factory with
reactive controls, used by all consuming test files. Migrated all 16
test files from ad-hoc mocks to the shared factory.
- **Files**: 62 files changed (rename propagation + new test infra)

## Review Focus

- Mock factory API design in `authStoreMock.ts` — covers all store
properties with reactive `controls` for per-test customization
- Self-test in `authStoreMock.test.ts` validates computed reactivity

Fixes #8219

## Stack

This is PR 1/5 in a stacked refactoring series:
1. **→ This PR**: Rename + shared test fixtures
2. #10484: Extract auth-routing from workspaceApi
3. #10485: Auth token priority tests
4. #10486: Decompose MembersPanelContent
5. #10487: Consolidate SubscriptionTier type

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-27 00:31:11 -07:00

732 lines
16 KiB
TypeScript

import axios from 'axios'
import { t } from '@/i18n'
import { api } from '@/scripts/api'
import { useAuthStore } from '@/stores/authStore'
export type WorkspaceType = 'personal' | 'team'
export type WorkspaceRole = 'owner' | 'member'
interface Workspace {
id: string
name: string
type: WorkspaceType
created_at: string
joined_at: string
}
export interface WorkspaceWithRole extends Workspace {
role: WorkspaceRole
subscription_tier?: SubscriptionTier
}
export interface Member {
id: string
name: string
email: string
joined_at: string
role: WorkspaceRole
}
interface PaginationInfo {
offset: number
limit: number
total: number
}
interface ListMembersResponse {
members: Member[]
pagination: PaginationInfo
}
export interface ListMembersParams {
offset?: number
limit?: number
}
export interface PendingInvite {
id: string
email: string
token: string
invited_at: string
expires_at: string
}
interface ListInvitesResponse {
invites: PendingInvite[]
}
interface CreateInviteRequest {
email: string
}
interface AcceptInviteResponse {
workspace_id: string
workspace_name: string
}
interface CreateWorkspacePayload {
name: string
}
interface UpdateWorkspacePayload {
name: string
}
interface ListWorkspacesResponse {
workspaces: WorkspaceWithRole[]
}
export type SubscriptionTier =
| 'FREE'
| 'STANDARD'
| 'CREATOR'
| 'PRO'
| 'FOUNDERS_EDITION'
export type SubscriptionDuration = 'MONTHLY' | 'ANNUAL'
type PlanAvailabilityReason =
| 'same_plan'
| 'incompatible_transition'
| 'requires_team'
| 'requires_personal'
| 'exceeds_max_seats'
interface PlanAvailability {
available: boolean
reason?: PlanAvailabilityReason
}
interface PlanSeatSummary {
seat_count: number
total_cost_cents: number
total_credits_cents: number
}
export interface Plan {
slug: string
tier: SubscriptionTier
duration: SubscriptionDuration
price_cents: number
credits_cents: number
max_seats: number
availability: PlanAvailability
seat_summary: PlanSeatSummary
}
interface BillingPlansResponse {
current_plan_slug?: string
plans: Plan[]
}
type SubscriptionTransitionType =
| 'new_subscription'
| 'upgrade'
| 'downgrade'
| 'duration_change'
interface PreviewSubscribeRequest {
plan_slug: string
}
interface SubscribeRequest {
plan_slug: string
idempotency_key?: string
return_url?: string
cancel_url?: string
}
type SubscribeStatus = 'subscribed' | 'needs_payment_method' | 'pending_payment'
export interface SubscribeResponse {
billing_op_id: string
status: SubscribeStatus
effective_at?: string
payment_method_url?: string
}
interface CancelSubscriptionRequest {
idempotency_key?: string
}
interface CancelSubscriptionResponse {
billing_op_id: string
cancel_at: string
}
interface ResubscribeRequest {
idempotency_key?: string
}
interface ResubscribeResponse {
billing_op_id: string
status: 'active'
message?: string
}
interface PaymentPortalRequest {
return_url?: string
}
interface PaymentPortalResponse {
url: string
}
interface PreviewPlanInfo {
slug: string
tier: SubscriptionTier
duration: SubscriptionDuration
price_cents: number
credits_cents: number
seat_summary: PlanSeatSummary
period_start?: string
period_end?: string
}
export interface PreviewSubscribeResponse {
allowed: boolean
reason?: string
transition_type: SubscriptionTransitionType
effective_at: string
is_immediate: boolean
cost_today_cents: number
cost_next_period_cents: number
credits_today_cents: number
credits_next_period_cents: number
current_plan?: PreviewPlanInfo
new_plan: PreviewPlanInfo
}
type BillingSubscriptionStatus = 'active' | 'scheduled' | 'ended' | 'canceled'
type BillingStatus =
| 'awaiting_payment_method'
| 'pending_payment'
| 'paid'
| 'payment_failed'
| 'inactive'
export interface BillingStatusResponse {
is_active: boolean
subscription_status?: BillingSubscriptionStatus
subscription_tier?: SubscriptionTier
subscription_duration?: SubscriptionDuration
plan_slug?: string
billing_status?: BillingStatus
has_funds: boolean
cancel_at?: string
renewal_date?: string
}
export interface BillingBalanceResponse {
amount_micros: number
prepaid_balance_micros?: number
cloud_credit_balance_micros?: number
pending_charges_micros?: number
effective_balance_micros?: number
currency: string
}
interface CreateTopupRequest {
amount_cents: number
idempotency_key?: string
}
type TopupStatus = 'pending' | 'completed' | 'failed'
interface CreateTopupResponse {
billing_op_id: string
topup_id: string
status: TopupStatus
amount_cents: number
}
type BillingOpStatus = 'pending' | 'succeeded' | 'failed'
export interface BillingOpStatusResponse {
id: string
status: BillingOpStatus
error_message?: string
started_at: string
completed_at?: string
}
interface BillingEvent {
event_type: string
event_id: string
params?: Record<string, unknown>
createdAt: string
}
interface BillingEventsResponse {
total: number
events: BillingEvent[]
page: number
limit: number
totalPages: number
}
interface GetBillingEventsParams {
page?: number
limit?: number
}
class WorkspaceApiError extends Error {
constructor(
message: string,
public readonly status?: number,
public readonly code?: string
) {
super(message)
this.name = 'WorkspaceApiError'
}
}
const workspaceApiClient = axios.create({
headers: {
'Content-Type': 'application/json'
}
})
async function getAuthHeaderOrThrow() {
const authHeader = await useAuthStore().getAuthHeader()
if (!authHeader) {
throw new WorkspaceApiError(
t('toastMessages.userNotAuthenticated'),
401,
'NOT_AUTHENTICATED'
)
}
return authHeader
}
async function getFirebaseHeaderOrThrow() {
const authHeader = await useAuthStore().getFirebaseAuthHeader()
if (!authHeader) {
throw new WorkspaceApiError(
t('toastMessages.userNotAuthenticated'),
401,
'NOT_AUTHENTICATED'
)
}
return authHeader
}
function handleAxiosError(err: unknown): never {
if (axios.isAxiosError(err)) {
const status = err.response?.status
const message = err.response?.data?.message ?? err.message
throw new WorkspaceApiError(message, status)
}
throw err
}
export const workspaceApi = {
/**
* List all workspaces the user has access to
* GET /api/workspaces
*/
async list(): Promise<ListWorkspacesResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.get<ListWorkspacesResponse>(
api.apiURL('/workspaces'),
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Create a new workspace
* POST /api/workspaces
*/
async create(payload: CreateWorkspacePayload): Promise<WorkspaceWithRole> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.post<WorkspaceWithRole>(
api.apiURL('/workspaces'),
payload,
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Update workspace name
* PATCH /api/workspaces/:id
*/
async update(
workspaceId: string,
payload: UpdateWorkspacePayload
): Promise<WorkspaceWithRole> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.patch<WorkspaceWithRole>(
api.apiURL(`/workspaces/${workspaceId}`),
payload,
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Delete a workspace (owner only)
* DELETE /api/workspaces/:id
*/
async delete(workspaceId: string): Promise<void> {
const headers = await getAuthHeaderOrThrow()
try {
await workspaceApiClient.delete(
api.apiURL(`/workspaces/${workspaceId}`),
{
headers
}
)
} catch (err) {
handleAxiosError(err)
}
},
/**
* Leave the current workspace.
* POST /api/workspace/leave
*/
async leave(): Promise<void> {
const headers = await getAuthHeaderOrThrow()
try {
await workspaceApiClient.post(api.apiURL('/workspace/leave'), null, {
headers
})
} catch (err) {
handleAxiosError(err)
}
},
/**
* List workspace members (paginated).
* GET /api/workspace/members
*/
async listMembers(params?: ListMembersParams): Promise<ListMembersResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.get<ListMembersResponse>(
api.apiURL('/workspace/members'),
{ headers, params }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Remove a member from the workspace.
* DELETE /api/workspace/members/:userId
*/
async removeMember(userId: string): Promise<void> {
const headers = await getAuthHeaderOrThrow()
try {
await workspaceApiClient.delete(
api.apiURL(`/workspace/members/${userId}`),
{ headers }
)
} catch (err) {
handleAxiosError(err)
}
},
/**
* List pending invites for the workspace.
* GET /api/workspace/invites
*/
async listInvites(): Promise<ListInvitesResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.get<ListInvitesResponse>(
api.apiURL('/workspace/invites'),
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Create an invite for the workspace.
* POST /api/workspace/invites
*/
async createInvite(payload: CreateInviteRequest): Promise<PendingInvite> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.post<PendingInvite>(
api.apiURL('/workspace/invites'),
payload,
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Revoke a pending invite.
* DELETE /api/workspace/invites/:inviteId
*/
async revokeInvite(inviteId: string): Promise<void> {
const headers = await getAuthHeaderOrThrow()
try {
await workspaceApiClient.delete(
api.apiURL(`/workspace/invites/${inviteId}`),
{ headers }
)
} catch (err) {
handleAxiosError(err)
}
},
/**
* Accept a workspace invite.
* POST /api/invites/:token/accept
* Uses Firebase auth (user identity) since the user isn't yet a workspace member.
*/
async acceptInvite(token: string): Promise<AcceptInviteResponse> {
const headers = await getFirebaseHeaderOrThrow()
try {
const response = await workspaceApiClient.post<AcceptInviteResponse>(
api.apiURL(`/invites/${token}/accept`),
null,
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Get billing status for the current workspace
* GET /api/billing/status
*/
async getBillingStatus(): Promise<BillingStatusResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.get<BillingStatusResponse>(
api.apiURL('/billing/status'),
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Get credit balance for the current workspace
* GET /api/billing/balance
*/
async getBillingBalance(): Promise<BillingBalanceResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.get<BillingBalanceResponse>(
api.apiURL('/billing/balance'),
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Get available subscription plans
* GET /api/billing/plans
*/
async getBillingPlans(): Promise<BillingPlansResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.get<BillingPlansResponse>(
api.apiURL('/billing/plans'),
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Preview subscription change
* POST /api/billing/preview-subscribe
*/
async previewSubscribe(planSlug: string): Promise<PreviewSubscribeResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.post<PreviewSubscribeResponse>(
api.apiURL('/billing/preview-subscribe'),
{ plan_slug: planSlug } satisfies PreviewSubscribeRequest,
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Subscribe to a billing plan
* POST /api/billing/subscribe
*/
async subscribe(
planSlug: string,
returnUrl?: string,
cancelUrl?: string
): Promise<SubscribeResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.post<SubscribeResponse>(
api.apiURL('/billing/subscribe'),
{
plan_slug: planSlug,
return_url: returnUrl,
cancel_url: cancelUrl
} satisfies SubscribeRequest,
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Cancel current subscription
* POST /api/billing/subscription/cancel
*/
async cancelSubscription(
idempotencyKey?: string
): Promise<CancelSubscriptionResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response =
await workspaceApiClient.post<CancelSubscriptionResponse>(
api.apiURL('/billing/subscription/cancel'),
{
idempotency_key: idempotencyKey
} satisfies CancelSubscriptionRequest,
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Resubscribe (undo cancel) before period ends
* POST /api/billing/subscription/resubscribe
*/
async resubscribe(idempotencyKey?: string): Promise<ResubscribeResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.post<ResubscribeResponse>(
api.apiURL('/billing/subscription/resubscribe'),
{ idempotency_key: idempotencyKey } satisfies ResubscribeRequest,
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Get Stripe payment portal URL for managing payment methods
* POST /api/billing/payment-portal
*/
async getPaymentPortalUrl(
returnUrl?: string
): Promise<PaymentPortalResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.post<PaymentPortalResponse>(
api.apiURL('/billing/payment-portal'),
{ return_url: returnUrl } satisfies PaymentPortalRequest,
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Create a credit top-up
* POST /api/billing/topup
*/
async createTopup(
amountCents: number,
idempotencyKey?: string
): Promise<CreateTopupResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.post<CreateTopupResponse>(
api.apiURL('/billing/topup'),
{
amount_cents: amountCents,
idempotency_key: idempotencyKey
} satisfies CreateTopupRequest,
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Get billing events
* GET /api/billing/events
*/
async getBillingEvents(
params?: GetBillingEventsParams
): Promise<BillingEventsResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.get<BillingEventsResponse>(
api.apiURL('/billing/events'),
{ headers, params }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Get billing operation status
* GET /api/billing/ops/:id
*/
async getBillingOpStatus(opId: string): Promise<BillingOpStatusResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.get<BillingOpStatusResponse>(
api.apiURL(`/billing/ops/${opId}`),
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
}
}