Compare commits

...

1 Commits

Author SHA1 Message Date
Hunter Senft-Grupp
a564b48787 feat: add account ban handling and banned views 2026-06-09 14:02:38 -04:00
17 changed files with 391 additions and 6 deletions

View File

@@ -2963,8 +2963,18 @@
"technicalDetails": "Technical Details",
"helpText": "Need help? Contact",
"supportLink": "support"
},
"banned": {
"title": "Your account has been banned",
"message": "Your account has been banned for misuse. If you believe this is a mistake, please contact support.",
"contactSupport": "Contact support",
"signOut": "Sign out"
}
},
"accountBanned": {
"toastSummary": "Account banned",
"toastDetail": "Your account has been banned for misuse. Please contact support@comfy.org if you believe this is a mistake."
},
"cloudFooter_needHelp": "Need Help?",
"cloudStart_title": "start creating in seconds",
"cloudStart_desc": "Zero setup required. Works on any device.",

View File

@@ -12,6 +12,8 @@ import { createApp } from 'vue'
import { VueFire, VueFireAuth } from 'vuefire'
import { setAssertReporter } from '@/base/assert'
import { onAccountBanned } from '@/platform/auth/accountBanned'
import { installAccountBannedFetchInterceptor } from '@/platform/auth/accountBannedInterceptors'
import { getFirebaseConfig } from '@/config/firebase'
import { flushProxyWidgetMigration } from '@/core/graph/subgraph/migration/proxyWidgetMigration'
import { autoExposeKnownPreviewNodes } from '@/core/graph/subgraph/promotionUtils'
@@ -29,7 +31,7 @@ import { useBootstrapStore } from '@/stores/bootstrapStore'
import App from './App.vue'
// Intentionally relative import to ensure the CSS is loaded in the right order (after litegraph.css)
import './assets/css/style.css'
import { i18n } from './i18n'
import { i18n, t } from './i18n'
/**
* CRITICAL: Load remote config FIRST for cloud builds to ensure
@@ -145,6 +147,27 @@ LGraph.proxyWidgetMigrationFlush = (hostNode, nodeData) =>
LGraph.autoExposePreviewNodes = (hostNode) =>
autoExposeKnownPreviewNodes(hostNode)
installAccountBannedFetchInterceptor()
let accountBannedHandled = false
onAccountBanned(() => {
if (accountBannedHandled) return
accountBannedHandled = true
if (isCloud) {
if (router.currentRoute.value.name !== 'cloud-banned') {
void router.replace({ name: 'cloud-banned' })
}
return
}
useToastStore(pinia).add({
severity: 'error',
summary: t('accountBanned.toastSummary'),
detail: t('accountBanned.toastDetail')
})
})
const bootstrapStore = useBootstrapStore(pinia)
void bootstrapStore.startStoreBootstrap()

View File

@@ -0,0 +1,45 @@
import { describe, expect, it, vi } from 'vitest'
import {
isAccountBannedResponseBody,
notifyAccountBanned,
onAccountBanned
} from '@/platform/auth/accountBanned'
describe('isAccountBannedResponseBody', () => {
it('is true when the body carries the ACCOUNT_BANNED code', () => {
expect(isAccountBannedResponseBody({ code: 'ACCOUNT_BANNED' })).toBe(true)
})
it('is false for an ordinary access-denied body', () => {
expect(isAccountBannedResponseBody({ code: 'ACCESS_DENIED' })).toBe(false)
})
it('is false for non-object bodies', () => {
expect(isAccountBannedResponseBody(null)).toBe(false)
expect(isAccountBannedResponseBody('ACCOUNT_BANNED')).toBe(false)
expect(isAccountBannedResponseBody(undefined)).toBe(false)
})
})
describe('account banned subscription', () => {
it('invokes subscribed listeners on notify', () => {
const listener = vi.fn()
const unsubscribe = onAccountBanned(listener)
notifyAccountBanned()
expect(listener).toHaveBeenCalledTimes(1)
unsubscribe()
})
it('stops invoking a listener after it unsubscribes', () => {
const listener = vi.fn()
const unsubscribe = onAccountBanned(listener)
unsubscribe()
notifyAccountBanned()
expect(listener).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,39 @@
import { get } from 'es-toolkit/compat'
/**
* Machine-readable error code returned in a 403 response body by the cloud
* backend (comfy-api at api.comfy.org and the cloud ingest server) when the
* account has been banned. It distinguishes a ban from an ordinary
* access-denied 403.
*/
const ACCOUNT_BANNED_CODE = 'ACCOUNT_BANNED'
export function isAccountBannedResponseBody(body: unknown): boolean {
return get(body, 'code') === ACCOUNT_BANNED_CODE
}
type AccountBannedListener = () => void
const listeners = new Set<AccountBannedListener>()
/**
* Subscribe to account-ban detection. Returns an unsubscribe function.
*
* Detection is decoupled from handling so any client that talks to an
* authenticated cloud surface (the local ComfyUI server on cloud, or the
* registry at api.comfy.org on every distribution) can report a ban through one
* channel, while the app decides how to surface it (route to a banned page on
* cloud, toast on local).
*/
export function onAccountBanned(listener: AccountBannedListener): () => void {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
export function notifyAccountBanned(): void {
for (const listener of listeners) {
listener()
}
}

View File

@@ -0,0 +1,123 @@
import axios, { AxiosError, AxiosHeaders } from 'axios'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { onAccountBanned } from '@/platform/auth/accountBanned'
import {
addAccountBannedInterceptor,
installAccountBannedFetchInterceptor
} from '@/platform/auth/accountBannedInterceptors'
vi.mock('@/config/comfyApi', () => ({
getComfyApiBaseUrl: () => 'https://api.comfy.org',
getComfyPlatformBaseUrl: () => 'https://platform.comfy.org'
}))
function bannedResponse(): Response {
return new Response(JSON.stringify({ code: 'ACCOUNT_BANNED' }), {
status: 403
})
}
describe('installAccountBannedFetchInterceptor', () => {
const originalFetch = globalThis.fetch
afterEach(() => {
globalThis.fetch = originalFetch
})
it('notifies listeners when one of our cloud hosts returns a banned 403', async () => {
globalThis.fetch = vi.fn().mockResolvedValue(bannedResponse())
installAccountBannedFetchInterceptor()
const onBanned = vi.fn()
const unsubscribe = onAccountBanned(onBanned)
await fetch('https://api.comfy.org/customers/balance')
await vi.waitFor(() => expect(onBanned).toHaveBeenCalledTimes(1))
unsubscribe()
})
it('ignores a banned 403 from a third-party host', async () => {
globalThis.fetch = vi.fn().mockResolvedValue(bannedResponse())
installAccountBannedFetchInterceptor()
const onBanned = vi.fn()
const unsubscribe = onAccountBanned(onBanned)
await fetch('https://evil.example.com/whatever')
await new Promise((resolve) => setTimeout(resolve, 0))
expect(onBanned).not.toHaveBeenCalled()
unsubscribe()
})
it('does not notify for an ordinary 403 and returns the body intact', async () => {
globalThis.fetch = vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ code: 'ACCESS_DENIED' }), { status: 403 })
)
installAccountBannedFetchInterceptor()
const onBanned = vi.fn()
const unsubscribe = onAccountBanned(onBanned)
const response = await fetch('https://api.comfy.org/customers/balance')
await new Promise((resolve) => setTimeout(resolve, 0))
expect(onBanned).not.toHaveBeenCalled()
expect(await response.json()).toEqual({ code: 'ACCESS_DENIED' })
unsubscribe()
})
})
describe('addAccountBannedInterceptor', () => {
let client: ReturnType<typeof axios.create>
beforeEach(() => {
client = axios.create()
addAccountBannedInterceptor(client)
})
it('notifies listeners when a request rejects with a banned 403', async () => {
client.interceptors.request.use(() =>
Promise.reject(
new AxiosError('banned', 'ERR_BAD_REQUEST', undefined, undefined, {
status: 403,
data: { code: 'ACCOUNT_BANNED' },
statusText: 'Forbidden',
headers: {},
config: { headers: new AxiosHeaders() }
})
)
)
const onBanned = vi.fn()
const unsubscribe = onAccountBanned(onBanned)
await expect(client.get('/whatever')).rejects.toThrow()
expect(onBanned).toHaveBeenCalledTimes(1)
unsubscribe()
})
it('does not notify for an ordinary 403 rejection', async () => {
client.interceptors.request.use(() =>
Promise.reject(
new AxiosError('denied', 'ERR_BAD_REQUEST', undefined, undefined, {
status: 403,
data: { code: 'ACCESS_DENIED' },
statusText: 'Forbidden',
headers: {},
config: { headers: new AxiosHeaders() }
})
)
)
const onBanned = vi.fn()
const unsubscribe = onAccountBanned(onBanned)
await expect(client.get('/whatever')).rejects.toThrow()
expect(onBanned).not.toHaveBeenCalled()
unsubscribe()
})
})

View File

@@ -0,0 +1,81 @@
import axios from 'axios'
import type { AxiosInstance } from 'axios'
import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from '@/config/comfyApi'
import {
isAccountBannedResponseBody,
notifyAccountBanned
} from '@/platform/auth/accountBanned'
/**
* Registers a response interceptor that reports an account ban whenever the
* client receives a 403 carrying the ACCOUNT_BANNED code, then rethrows so each
* caller's existing error handling is unaffected.
*/
export function addAccountBannedInterceptor(client: AxiosInstance): void {
client.interceptors.response.use(undefined, (error: unknown) => {
if (
axios.isAxiosError(error) &&
error.response?.status === 403 &&
isAccountBannedResponseBody(error.response.data)
) {
notifyAccountBanned()
}
return Promise.reject(error)
})
}
/**
* Wraps the global fetch so a banned 403 from a fetch-based call to one of our
* cloud hosts (the comfy-api registry, the cloud ingest server, the platform,
* subscriptions, etc.) reports an account ban. Responses from third-party hosts
* are ignored, and the original response is returned untouched to callers.
*/
export function installAccountBannedFetchInterceptor(): void {
const originalFetch = globalThis.fetch.bind(globalThis)
globalThis.fetch = async (
...args: Parameters<typeof fetch>
): Promise<Response> => {
const response = await originalFetch(...args)
if (response.status === 403 && isOurCloudUrl(requestUrl(args[0]))) {
void reportIfBanned(response.clone())
}
return response
}
}
function requestUrl(input: Parameters<typeof fetch>[0]): string {
if (typeof input === 'string') return input
if (input instanceof URL) return input.href
return input.url
}
function isOurCloudUrl(url: string): boolean {
try {
const host = new URL(url, globalThis.location?.href).host
return ourCloudHosts().has(host)
} catch {
return false
}
}
function ourCloudHosts(): Set<string> {
const hosts = new Set<string>()
hosts.add(new URL(getComfyApiBaseUrl()).host)
hosts.add(new URL(getComfyPlatformBaseUrl()).host)
if (globalThis.location?.host) {
hosts.add(globalThis.location.host)
}
return hosts
}
async function reportIfBanned(response: Response): Promise<void> {
try {
const body: unknown = await response.json()
if (isAccountBannedResponseBody(body)) {
notifyAccountBanned()
}
} catch {
// Body was not banned-shaped JSON; treat as an ordinary 403.
}
}

View File

@@ -0,0 +1,38 @@
<template>
<div class="flex h-full items-center justify-center p-6">
<div class="max-w-[100vw] text-center lg:w-[500px]">
<h2 class="mb-3 text-xl text-text-primary">
{{ $t('cloudOnboarding.banned.title') }}
</h2>
<p class="mb-5 text-muted">
{{ $t('cloudOnboarding.banned.message') }}
</p>
<div class="flex flex-col gap-3">
<Button as="a" :href="supportUrl" target="_blank" rel="noopener">
{{ $t('cloudOnboarding.banned.contactSupport') }}
</Button>
<Button variant="textonly" @click="handleSignOut">
{{ $t('cloudOnboarding.banned.signOut') }}
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
const supportUrl = 'https://support.comfy.org'
const router = useRouter()
const { logout } = useAuthActions()
const handleSignOut = async () => {
await logout()
await router.replace({ name: 'cloud-login' })
}
</script>

View File

@@ -114,6 +114,12 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
component: () =>
import('@/platform/cloud/onboarding/CloudSorryContactSupportView.vue')
},
{
path: 'banned',
name: 'cloud-banned',
component: () =>
import('@/platform/cloud/onboarding/CloudBannedView.vue')
},
{
path: 'auth-timeout',
name: 'cloud-auth-timeout',

View File

@@ -5,7 +5,8 @@ import { useReleaseService } from '@/platform/updates/common/releaseService'
// Hoist the mock to avoid hoisting issues
const mockAxiosInstance = vi.hoisted(() => ({
get: vi.fn()
get: vi.fn(),
interceptors: { response: { use: vi.fn() } }
}))
vi.mock('axios', () => ({

View File

@@ -3,6 +3,7 @@ import axios from 'axios'
import { ref, watch } from 'vue'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { addAccountBannedInterceptor } from '@/platform/auth/accountBannedInterceptors'
import type { components, operations } from '@/types/comfyRegistryTypes'
import { isAbortError } from '@/utils/typeGuardUtil'
@@ -20,6 +21,8 @@ const releaseApiClient = axios.create({
}
})
addAccountBannedInterceptor(releaseApiClient)
// Release service for fetching release notes
export const useReleaseService = () => {
const isLoading = ref(false)

View File

@@ -9,7 +9,8 @@ const {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn()
delete: vi.fn(),
interceptors: { response: { use: vi.fn() } }
},
mockGetAuthHeaderOrThrow: vi.fn(),
mockGetFirebaseAuthHeaderOrThrow: vi.fn()

View File

@@ -1,5 +1,6 @@
import axios from 'axios'
import { addAccountBannedInterceptor } from '@/platform/auth/accountBannedInterceptors'
import type { SubscriptionTier } from '@/platform/cloud/subscription/constants/tierPricing'
import type {
WorkspaceId,
@@ -287,6 +288,8 @@ const workspaceApiClient = axios.create({
}
})
addAccountBannedInterceptor(workspaceApiClient)
async function getAuthHeaderOrThrow() {
return useAuthStore().getAuthHeaderOrThrow()
}

View File

@@ -134,14 +134,16 @@ if (isCloud) {
'cloud-signup',
'cloud-forgot-password',
'cloud-oauth-consent',
'cloud-sorry-contact-support'
'cloud-sorry-contact-support',
'cloud-banned'
])
const PUBLIC_ROUTE_PATHS = new Set([
'/cloud/login',
'/cloud/signup',
'/cloud/forgot-password',
'/cloud/oauth/consent',
'/cloud/sorry-contact-support'
'/cloud/sorry-contact-support',
'/cloud/banned'
])
function isPublicRoute(to: RouteLocationNormalized) {

View File

@@ -2,6 +2,7 @@ import type { AxiosError, AxiosResponse } from 'axios'
import axios from 'axios'
import { ref } from 'vue'
import { addAccountBannedInterceptor } from '@/platform/auth/accountBannedInterceptors'
import type { components, operations } from '@/types/comfyRegistryTypes'
import { isAbortError } from '@/utils/typeGuardUtil'
@@ -18,6 +19,8 @@ const registryApiClient = axios.create({
}
})
addAccountBannedInterceptor(registryApiClient)
/**
* Service for interacting with the Comfy Registry API
*/

View File

@@ -8,7 +8,8 @@ import {
// Hoist the mocks to avoid hoisting issues
const mockAxiosInstance = vi.hoisted(() => ({
get: vi.fn()
get: vi.fn(),
interceptors: { response: { use: vi.fn() } }
}))
const mockAuthStore = vi.hoisted(() => ({

View File

@@ -4,6 +4,7 @@ import { ref, watch } from 'vue'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { d } from '@/i18n'
import { addAccountBannedInterceptor } from '@/platform/auth/accountBannedInterceptors'
import { useAuthStore } from '@/stores/authStore'
import type { components, operations } from '@/types/comfyRegistryTypes'
import { isAbortError } from '@/utils/typeGuardUtil'
@@ -30,6 +31,8 @@ const customerApiClient = axios.create({
}
})
addAccountBannedInterceptor(customerApiClient)
export const useCustomerEventsService = () => {
const isLoading = ref(false)
const error = ref<string | null>(null)

View File

@@ -3,6 +3,7 @@ import axios from 'axios'
import { v4 as uuidv4 } from 'uuid'
import { ref } from 'vue'
import { addAccountBannedInterceptor } from '@/platform/auth/accountBannedInterceptors'
import { api } from '@/scripts/api'
import { isAbortError } from '@/utils/typeGuardUtil'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
@@ -45,6 +46,8 @@ const managerApiClient = axios.create({
}
})
addAccountBannedInterceptor(managerApiClient)
/**
* Service for interacting with the ComfyUI Manager API
* Provides methods for managing packs, ComfyUI-Manager queue operations, and system functions