mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
Fix version detection for disabled packs (#5395)
* fix: normalize pack IDs to fix version detection for disabled packs When a pack is disabled, ComfyUI-Manager returns it with a version suffix (e.g., "ComfyUI-GGUF@1_1_4") while enabled packs don't have this suffix. This inconsistency caused disabled packs to incorrectly show as having updates available even when they were on the latest version. Changes: - Add normalizePackId utility to consistently remove version suffixes - Apply normalization in refreshInstalledList and WebSocket updates - Use the utility across conflict detection and node help modules - Ensure pack version info is preserved in the object's ver field This fixes the "Update Available" indicator incorrectly showing for disabled packs that are already on the latest version. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * feature: test code added * test: packUtils test code added * test: address PR review feedback for test improvements - Remove unnecessary .not.toThrow() assertion in useManagerQueue test - Add clarifying comments for version normalization test logic - Replace 'as any' with vi.mocked() for better type safety --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,7 @@ import type {
|
||||
NodePackRequirements,
|
||||
SystemEnvironment
|
||||
} from '@/types/conflictDetectionTypes'
|
||||
import { normalizePackId } from '@/utils/packUtils'
|
||||
import {
|
||||
cleanVersion,
|
||||
satisfiesVersion,
|
||||
@@ -874,9 +875,7 @@ function mergeConflictsByPackageName(
|
||||
|
||||
conflicts.forEach((conflict) => {
|
||||
// Normalize package name by removing version suffix (@1_0_3) for consistent merging
|
||||
const normalizedPackageName = conflict.package_name.includes('@')
|
||||
? conflict.package_name.substring(0, conflict.package_name.indexOf('@'))
|
||||
: conflict.package_name
|
||||
const normalizedPackageName = normalizePackId(conflict.package_name)
|
||||
|
||||
if (mergedMap.has(normalizedPackageName)) {
|
||||
// Package already exists, merge conflicts
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Ref, computed, ref } from 'vue'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { components } from '@/types/generatedManagerTypes'
|
||||
import { normalizePackKeys } from '@/utils/packUtils'
|
||||
|
||||
type ManagerTaskHistory = Record<
|
||||
string,
|
||||
@@ -98,7 +99,8 @@ export const useManagerQueue = (
|
||||
taskHistory.value = filterHistoryByClientId(state.history)
|
||||
|
||||
if (state.installed_packs) {
|
||||
installedPacks.value = state.installed_packs
|
||||
// Normalize pack keys to ensure consistent access
|
||||
installedPacks.value = normalizePackKeys(state.installed_packs)
|
||||
}
|
||||
updateProcessingState()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEventListener, whenever } from '@vueuse/core'
|
||||
import { mapKeys } from 'es-toolkit/compat'
|
||||
import { defineStore } from 'pinia'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { ref, watch } from 'vue'
|
||||
@@ -14,6 +13,7 @@ import { useComfyManagerService } from '@/services/comfyManagerService'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { TaskLog } from '@/types/comfyManagerTypes'
|
||||
import { components } from '@/types/generatedManagerTypes'
|
||||
import { normalizePackKeys } from '@/utils/packUtils'
|
||||
|
||||
type InstallPackParams = components['schemas']['InstallPackParams']
|
||||
type InstalledPacksResponse = components['schemas']['InstalledPacksResponse']
|
||||
@@ -185,12 +185,8 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
|
||||
const refreshInstalledList = async () => {
|
||||
const packs = await managerService.listInstalledPacks()
|
||||
if (packs) {
|
||||
// The keys are 'cleaned' by stripping the version suffix.
|
||||
// The pack object itself (the value) still contains the version info.
|
||||
const packsWithCleanedKeys = mapKeys(packs, (_value, key) => {
|
||||
return key.split('@')[0]
|
||||
})
|
||||
installedPacks.value = packsWithCleanedKeys
|
||||
// Normalize pack keys to ensure consistent access
|
||||
installedPacks.value = normalizePackKeys(packs)
|
||||
}
|
||||
isStale.value = false
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { NodeSourceType, getNodeSource } from '@/types/nodeSource'
|
||||
import { normalizePackId } from '@/utils/packUtils'
|
||||
|
||||
export function extractCustomNodeName(
|
||||
pythonModule: string | undefined
|
||||
): string | null {
|
||||
const modules = pythonModule?.split('.') || []
|
||||
if (modules.length >= 2 && modules[0] === 'custom_nodes') {
|
||||
return modules[1].split('@')[0]
|
||||
// Use normalizePackId to remove version suffix
|
||||
return normalizePackId(modules[1])
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
35
src/utils/packUtils.ts
Normal file
35
src/utils/packUtils.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { mapKeys } from 'es-toolkit/compat'
|
||||
|
||||
/**
|
||||
* Normalizes a pack ID by removing the version suffix.
|
||||
*
|
||||
* ComfyUI-Manager returns pack IDs in different formats:
|
||||
* - Enabled packs: "packname" (without version)
|
||||
* - Disabled packs: "packname@1_0_3" (with version suffix)
|
||||
* - Latest versions from registry: "packname" (without version)
|
||||
*
|
||||
* Since the pack object itself contains the version info (ver field),
|
||||
* we normalize all pack IDs to just the base name for consistent access.
|
||||
* This ensures we can always find a pack by its base name (nodePack.id)
|
||||
* regardless of its enabled/disabled state.
|
||||
*
|
||||
* @param packId - The pack ID that may contain a version suffix
|
||||
* @returns The normalized pack ID without version suffix
|
||||
*/
|
||||
export function normalizePackId(packId: string): string {
|
||||
return packId.split('@')[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes all keys in a pack record by removing version suffixes.
|
||||
* This is used when receiving pack data from the server to ensure
|
||||
* consistent key format across the application.
|
||||
*
|
||||
* @param packs - Record of packs with potentially versioned keys
|
||||
* @returns Record with normalized keys
|
||||
*/
|
||||
export function normalizePackKeys<T>(
|
||||
packs: Record<string, T>
|
||||
): Record<string, T> {
|
||||
return mapKeys(packs, (_value, key) => normalizePackId(key))
|
||||
}
|
||||
@@ -161,5 +161,62 @@ describe('useManagerQueue', () => {
|
||||
expect(taskHistory.value).toHaveProperty('task1')
|
||||
expect(taskHistory.value).not.toHaveProperty('task2')
|
||||
})
|
||||
|
||||
it('normalizes pack IDs when updating installed packs', () => {
|
||||
const queue = createManagerQueue()
|
||||
|
||||
const mockState = {
|
||||
history: {},
|
||||
running_queue: [],
|
||||
pending_queue: [],
|
||||
installed_packs: {
|
||||
'ComfyUI-GGUF@1_1_4': {
|
||||
enabled: false,
|
||||
cnr_id: 'ComfyUI-GGUF',
|
||||
ver: '1.1.4'
|
||||
},
|
||||
'test-pack': {
|
||||
enabled: true,
|
||||
cnr_id: 'test-pack',
|
||||
ver: '2.0.0'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
queue.updateTaskState(mockState)
|
||||
|
||||
// Packs should be accessible by normalized keys
|
||||
expect(installedPacks.value['ComfyUI-GGUF']).toEqual({
|
||||
enabled: false,
|
||||
cnr_id: 'ComfyUI-GGUF',
|
||||
ver: '1.1.4'
|
||||
})
|
||||
expect(installedPacks.value['test-pack']).toEqual({
|
||||
enabled: true,
|
||||
cnr_id: 'test-pack',
|
||||
ver: '2.0.0'
|
||||
})
|
||||
|
||||
// Version suffixed keys should not exist after normalization
|
||||
// The pack should be accessible by its base name only (without @version)
|
||||
expect(installedPacks.value['ComfyUI-GGUF@1_1_4']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('handles empty installed_packs gracefully', () => {
|
||||
const queue = createManagerQueue()
|
||||
|
||||
const mockState: any = {
|
||||
history: {},
|
||||
running_queue: [],
|
||||
pending_queue: [],
|
||||
installed_packs: undefined
|
||||
}
|
||||
|
||||
// Just call the function - if it throws, the test will fail automatically
|
||||
queue.updateTaskState(mockState)
|
||||
|
||||
// installedPacks should remain unchanged
|
||||
expect(installedPacks.value).toEqual({})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -439,4 +439,97 @@ describe('useComfyManagerStore', () => {
|
||||
expect(store.isPackInstalling('pack-3')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('refreshInstalledList with pack ID normalization', () => {
|
||||
it('normalizes pack IDs by removing version suffixes', async () => {
|
||||
const mockPacks = {
|
||||
'ComfyUI-GGUF@1_1_4': {
|
||||
enabled: false,
|
||||
cnr_id: 'ComfyUI-GGUF',
|
||||
ver: '1.1.4',
|
||||
aux_id: undefined
|
||||
},
|
||||
'ComfyUI-Manager': {
|
||||
enabled: true,
|
||||
cnr_id: 'ComfyUI-Manager',
|
||||
ver: '2.0.0',
|
||||
aux_id: undefined
|
||||
}
|
||||
}
|
||||
|
||||
vi.mocked(mockManagerService.listInstalledPacks).mockResolvedValue(
|
||||
mockPacks
|
||||
)
|
||||
|
||||
const store = useComfyManagerStore()
|
||||
await store.refreshInstalledList()
|
||||
|
||||
// Both packs should be accessible by their base name
|
||||
expect(store.installedPacks['ComfyUI-GGUF']).toEqual({
|
||||
enabled: false,
|
||||
cnr_id: 'ComfyUI-GGUF',
|
||||
ver: '1.1.4',
|
||||
aux_id: undefined
|
||||
})
|
||||
expect(store.installedPacks['ComfyUI-Manager']).toEqual({
|
||||
enabled: true,
|
||||
cnr_id: 'ComfyUI-Manager',
|
||||
ver: '2.0.0',
|
||||
aux_id: undefined
|
||||
})
|
||||
|
||||
// Version suffixed keys should not exist
|
||||
expect(store.installedPacks['ComfyUI-GGUF@1_1_4']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('handles duplicate keys after normalization', async () => {
|
||||
const mockPacks = {
|
||||
'test-pack': {
|
||||
enabled: true,
|
||||
cnr_id: 'test-pack',
|
||||
ver: '1.0.0',
|
||||
aux_id: undefined
|
||||
},
|
||||
'test-pack@1_1_0': {
|
||||
enabled: false,
|
||||
cnr_id: 'test-pack',
|
||||
ver: '1.1.0',
|
||||
aux_id: undefined
|
||||
}
|
||||
}
|
||||
|
||||
vi.mocked(mockManagerService.listInstalledPacks).mockResolvedValue(
|
||||
mockPacks
|
||||
)
|
||||
|
||||
const store = useComfyManagerStore()
|
||||
await store.refreshInstalledList()
|
||||
|
||||
// The normalized key should exist (last one wins with mapKeys)
|
||||
expect(store.installedPacks['test-pack']).toBeDefined()
|
||||
expect(store.installedPacks['test-pack'].ver).toBe('1.1.0')
|
||||
})
|
||||
|
||||
it('preserves version information for disabled packs', async () => {
|
||||
const mockPacks = {
|
||||
'disabled-pack@2_0_0': {
|
||||
enabled: false,
|
||||
cnr_id: 'disabled-pack',
|
||||
ver: '2.0.0',
|
||||
aux_id: undefined
|
||||
}
|
||||
}
|
||||
|
||||
vi.mocked(mockManagerService.listInstalledPacks).mockResolvedValue(
|
||||
mockPacks
|
||||
)
|
||||
|
||||
const store = useComfyManagerStore()
|
||||
await store.refreshInstalledList()
|
||||
|
||||
// Pack should be accessible by base name with version preserved
|
||||
expect(store.getInstalledPackVersion('disabled-pack')).toBe('2.0.0')
|
||||
expect(store.isPackInstalled('disabled-pack')).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
254
tests-ui/tests/utils/packUtils.test.ts
Normal file
254
tests-ui/tests/utils/packUtils.test.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { normalizePackId, normalizePackKeys } from '@/utils/packUtils'
|
||||
|
||||
describe('packUtils', () => {
|
||||
describe('normalizePackId', () => {
|
||||
it('should return pack ID unchanged when no version suffix exists', () => {
|
||||
expect(normalizePackId('ComfyUI-GGUF')).toBe('ComfyUI-GGUF')
|
||||
expect(normalizePackId('ComfyUI-Manager')).toBe('ComfyUI-Manager')
|
||||
expect(normalizePackId('simple-pack')).toBe('simple-pack')
|
||||
})
|
||||
|
||||
it('should remove version suffix with underscores', () => {
|
||||
expect(normalizePackId('ComfyUI-GGUF@1_1_4')).toBe('ComfyUI-GGUF')
|
||||
expect(normalizePackId('ComfyUI-Manager@2_0_0')).toBe('ComfyUI-Manager')
|
||||
expect(normalizePackId('pack@1_0_0_beta')).toBe('pack')
|
||||
})
|
||||
|
||||
it('should remove version suffix with dots', () => {
|
||||
expect(normalizePackId('ComfyUI-GGUF@1.1.4')).toBe('ComfyUI-GGUF')
|
||||
expect(normalizePackId('pack@2.0.0')).toBe('pack')
|
||||
})
|
||||
|
||||
it('should handle multiple @ symbols by only removing after first @', () => {
|
||||
expect(normalizePackId('pack@1_0_0@extra')).toBe('pack')
|
||||
expect(normalizePackId('my@pack@1_0_0')).toBe('my')
|
||||
})
|
||||
|
||||
it('should handle empty string', () => {
|
||||
expect(normalizePackId('')).toBe('')
|
||||
})
|
||||
|
||||
it('should handle pack ID with @ but no version', () => {
|
||||
expect(normalizePackId('pack@')).toBe('pack')
|
||||
})
|
||||
|
||||
it('should handle special characters in pack name', () => {
|
||||
expect(normalizePackId('my-pack_v2@1_0_0')).toBe('my-pack_v2')
|
||||
expect(normalizePackId('pack.with.dots@2_0_0')).toBe('pack.with.dots')
|
||||
expect(normalizePackId('UPPERCASE-Pack@1_0_0')).toBe('UPPERCASE-Pack')
|
||||
})
|
||||
|
||||
it('should handle edge cases', () => {
|
||||
// Only @ symbol
|
||||
expect(normalizePackId('@')).toBe('')
|
||||
expect(normalizePackId('@1_0_0')).toBe('')
|
||||
|
||||
// Whitespace
|
||||
expect(normalizePackId(' pack @1_0_0')).toBe(' pack ')
|
||||
expect(normalizePackId('pack @1_0_0')).toBe('pack ')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizePackKeys', () => {
|
||||
it('should normalize all keys with version suffixes', () => {
|
||||
const input = {
|
||||
'ComfyUI-GGUF': { ver: '1.1.4', enabled: true },
|
||||
'ComfyUI-Manager@2_0_0': { ver: '2.0.0', enabled: false },
|
||||
'another-pack@1_0_0': { ver: '1.0.0', enabled: true }
|
||||
}
|
||||
|
||||
const expected = {
|
||||
'ComfyUI-GGUF': { ver: '1.1.4', enabled: true },
|
||||
'ComfyUI-Manager': { ver: '2.0.0', enabled: false },
|
||||
'another-pack': { ver: '1.0.0', enabled: true }
|
||||
}
|
||||
|
||||
expect(normalizePackKeys(input)).toEqual(expected)
|
||||
})
|
||||
|
||||
it('should handle empty object', () => {
|
||||
expect(normalizePackKeys({})).toEqual({})
|
||||
})
|
||||
|
||||
it('should handle keys without version suffixes', () => {
|
||||
const input = {
|
||||
pack1: { data: 'value1' },
|
||||
pack2: { data: 'value2' }
|
||||
}
|
||||
|
||||
expect(normalizePackKeys(input)).toEqual(input)
|
||||
})
|
||||
|
||||
it('should handle mixed keys (with and without versions)', () => {
|
||||
const input = {
|
||||
'normal-pack': { ver: '1.0.0' },
|
||||
'versioned-pack@2_0_0': { ver: '2.0.0' },
|
||||
'another-normal': { ver: '3.0.0' },
|
||||
'another-versioned@4_0_0': { ver: '4.0.0' }
|
||||
}
|
||||
|
||||
const expected = {
|
||||
'normal-pack': { ver: '1.0.0' },
|
||||
'versioned-pack': { ver: '2.0.0' },
|
||||
'another-normal': { ver: '3.0.0' },
|
||||
'another-versioned': { ver: '4.0.0' }
|
||||
}
|
||||
|
||||
expect(normalizePackKeys(input)).toEqual(expected)
|
||||
})
|
||||
|
||||
it('should handle duplicate keys after normalization (last one wins)', () => {
|
||||
const input = {
|
||||
'pack@1_0_0': { ver: '1.0.0', data: 'first' },
|
||||
'pack@2_0_0': { ver: '2.0.0', data: 'second' },
|
||||
pack: { ver: '3.0.0', data: 'third' }
|
||||
}
|
||||
|
||||
const result = normalizePackKeys(input)
|
||||
|
||||
// The exact behavior depends on object iteration order,
|
||||
// but there should only be one 'pack' key in the result
|
||||
expect(Object.keys(result)).toEqual(['pack'])
|
||||
expect(result.pack).toBeDefined()
|
||||
expect(result.pack.ver).toBeDefined()
|
||||
})
|
||||
|
||||
it('should preserve value references', () => {
|
||||
const value1 = { ver: '1.0.0', complex: { nested: 'data' } }
|
||||
const value2 = { ver: '2.0.0', complex: { nested: 'data2' } }
|
||||
|
||||
const input = {
|
||||
'pack1@1_0_0': value1,
|
||||
'pack2@2_0_0': value2
|
||||
}
|
||||
|
||||
const result = normalizePackKeys(input)
|
||||
|
||||
// Values should be the same references, not cloned
|
||||
expect(result.pack1).toBe(value1)
|
||||
expect(result.pack2).toBe(value2)
|
||||
})
|
||||
|
||||
it('should handle special characters in keys', () => {
|
||||
const input = {
|
||||
'@1_0_0': { ver: '1.0.0' },
|
||||
'my-pack.v2@2_0_0': { ver: '2.0.0' },
|
||||
'UPPERCASE@3_0_0': { ver: '3.0.0' }
|
||||
}
|
||||
|
||||
const expected = {
|
||||
'': { ver: '1.0.0' },
|
||||
'my-pack.v2': { ver: '2.0.0' },
|
||||
UPPERCASE: { ver: '3.0.0' }
|
||||
}
|
||||
|
||||
expect(normalizePackKeys(input)).toEqual(expected)
|
||||
})
|
||||
|
||||
it('should work with different value types', () => {
|
||||
const input = {
|
||||
'pack1@1_0_0': 'string value',
|
||||
'pack2@2_0_0': 123,
|
||||
'pack3@3_0_0': null,
|
||||
'pack4@4_0_0': undefined,
|
||||
'pack5@5_0_0': true,
|
||||
pack6: []
|
||||
}
|
||||
|
||||
const expected = {
|
||||
pack1: 'string value',
|
||||
pack2: 123,
|
||||
pack3: null,
|
||||
pack4: undefined,
|
||||
pack5: true,
|
||||
pack6: []
|
||||
}
|
||||
|
||||
expect(normalizePackKeys(input)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Integration scenarios from JSDoc examples', () => {
|
||||
it('should handle the examples from normalizePackId JSDoc', () => {
|
||||
expect(normalizePackId('ComfyUI-GGUF')).toBe('ComfyUI-GGUF')
|
||||
expect(normalizePackId('ComfyUI-GGUF@1_1_4')).toBe('ComfyUI-GGUF')
|
||||
})
|
||||
|
||||
it('should handle the examples from normalizePackKeys JSDoc', () => {
|
||||
const input = {
|
||||
'ComfyUI-GGUF': { ver: '1.1.4', enabled: true },
|
||||
'ComfyUI-Manager@2_0_0': { ver: '2.0.0', enabled: false }
|
||||
}
|
||||
|
||||
const expected = {
|
||||
'ComfyUI-GGUF': { ver: '1.1.4', enabled: true },
|
||||
'ComfyUI-Manager': { ver: '2.0.0', enabled: false }
|
||||
}
|
||||
|
||||
expect(normalizePackKeys(input)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Real-world scenarios', () => {
|
||||
it('should handle typical ComfyUI-Manager response with mixed enabled/disabled packs', () => {
|
||||
// Simulating actual server response pattern
|
||||
const serverResponse = {
|
||||
// Enabled packs come without version suffix
|
||||
'ComfyUI-Essential': { ver: '1.2.3', enabled: true, aux_id: undefined },
|
||||
'ComfyUI-Impact': { ver: '2.0.0', enabled: true, aux_id: undefined },
|
||||
// Disabled packs come with version suffix
|
||||
'ComfyUI-GGUF@1_1_4': {
|
||||
ver: '1.1.4',
|
||||
enabled: false,
|
||||
aux_id: undefined
|
||||
},
|
||||
'ComfyUI-Manager@2_5_0': {
|
||||
ver: '2.5.0',
|
||||
enabled: false,
|
||||
aux_id: undefined
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = normalizePackKeys(serverResponse)
|
||||
|
||||
// All keys should be normalized (no version suffixes)
|
||||
expect(Object.keys(normalized)).toEqual([
|
||||
'ComfyUI-Essential',
|
||||
'ComfyUI-Impact',
|
||||
'ComfyUI-GGUF',
|
||||
'ComfyUI-Manager'
|
||||
])
|
||||
|
||||
// Values should be preserved
|
||||
expect(normalized['ComfyUI-GGUF']).toEqual({
|
||||
ver: '1.1.4',
|
||||
enabled: false,
|
||||
aux_id: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow consistent access by pack ID regardless of enabled state', () => {
|
||||
const packsBeforeToggle = {
|
||||
'my-pack': { ver: '1.0.0', enabled: true }
|
||||
}
|
||||
|
||||
const packsAfterToggle = {
|
||||
'my-pack@1_0_0': { ver: '1.0.0', enabled: false }
|
||||
}
|
||||
|
||||
const normalizedBefore = normalizePackKeys(packsBeforeToggle)
|
||||
const normalizedAfter = normalizePackKeys(packsAfterToggle)
|
||||
|
||||
// Both should have the same key after normalization
|
||||
expect(normalizedBefore['my-pack']).toBeDefined()
|
||||
expect(normalizedAfter['my-pack']).toBeDefined()
|
||||
|
||||
// Can access by the same key regardless of the original format
|
||||
expect(Object.keys(normalizedBefore)).toEqual(
|
||||
Object.keys(normalizedAfter)
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user