mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-21 14:59:39 +00:00
feat: add user secrets management panel (#8473)
## Summary Add a Secrets panel to user settings for managing third-party API keys (HuggingFace, Civitai). Secrets are encrypted server-side; plaintext values are never returned. ## Changes - Add `SecretsPanel` to settings for managing third-party API keys - Create `secretsApi` service following the `workspaceApi` pattern - Add `SecretListItem` and `SecretFormDialog` components - Add `user_secrets_enabled` feature flag - Support HuggingFace and Civitai providers - Add i18n translations for secrets UI ## Files Added - `src/platform/secrets/types.ts` - TypeScript types - `src/platform/secrets/api/secretsApi.ts` - Axios-based API service - `src/platform/secrets/components/SecretsPanel.vue` - Main settings panel - `src/platform/secrets/components/SecretListItem.vue` - Individual secret row - `src/platform/secrets/components/SecretFormDialog.vue` - Create/edit dialog ## Files Modified - `src/platform/remoteConfig/types.ts` - Add `user_secrets_enabled` flag type - `src/composables/useFeatureFlags.ts` - Add flag getter - `src/platform/settings/composables/useSettingUI.ts` - Integrate secrets panel - `src/locales/en/main.json` - Add translations ## Testing Panel appears in Settings under: - "Workspace" group when team workspaces is enabled - "Account" group in legacy mode Only visible when user is logged in AND `user_secrets_enabled` feature flag is enabled. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8473-feat-add-user-secrets-management-panel-2f86d73d36508187b4a1ed04ce07ce51) by [Unito](https://www.unito.io) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Secrets management UI in Settings with create/edit/delete for API keys/secrets; settings dialog entry and contextual Secrets hint in upload/import flows (feature-flag gated). * **APIs** * Added backend-facing secrets CRUD surface and client-side form/composable support for managing secrets. * **Localization** * New English translations for Secrets UI and many expanded asset import/upload error and hint messages. * **Tests** * Comprehensive unit tests for secrets UI, form flows, and composables. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
committed by
GitHub
parent
139ee32d78
commit
bc19bb60fb
364
src/platform/secrets/composables/useSecretForm.test.ts
Normal file
364
src/platform/secrets/composables/useSecretForm.test.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import { ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { SecretMetadata, SecretProvider } from '../types'
|
||||
import { useSecretForm } from './useSecretForm'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key })
|
||||
}))
|
||||
|
||||
const mockCreate = vi.fn()
|
||||
const mockUpdate = vi.fn()
|
||||
|
||||
vi.mock('../api/secretsApi', () => ({
|
||||
createSecret: (payload: unknown) => mockCreate(payload),
|
||||
updateSecret: (id: string, payload: unknown) => mockUpdate(id, payload),
|
||||
SecretsApiError: class SecretsApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status?: number,
|
||||
public readonly code?: string
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'SecretsApiError'
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
function createMockSecret(
|
||||
overrides: Partial<SecretMetadata> = {}
|
||||
): SecretMetadata {
|
||||
return {
|
||||
id: 'secret-1',
|
||||
name: 'Test Secret',
|
||||
provider: 'huggingface',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('useSecretForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('validation via handleSubmit', () => {
|
||||
it('requires name in create mode', async () => {
|
||||
const visible = ref(true)
|
||||
const { form, errors, handleSubmit } = useSecretForm({
|
||||
mode: 'create',
|
||||
existingProviders: () => [],
|
||||
visible,
|
||||
onSaved: vi.fn()
|
||||
})
|
||||
|
||||
form.name = ''
|
||||
form.provider = 'huggingface'
|
||||
form.secretValue = 'secret123'
|
||||
|
||||
await handleSubmit()
|
||||
|
||||
expect(mockCreate).not.toHaveBeenCalled()
|
||||
expect(errors.name).toBe('secrets.errors.nameRequired')
|
||||
})
|
||||
|
||||
it('requires name not to exceed 255 characters', async () => {
|
||||
const visible = ref(true)
|
||||
const { form, errors, handleSubmit } = useSecretForm({
|
||||
mode: 'create',
|
||||
existingProviders: () => [],
|
||||
visible,
|
||||
onSaved: vi.fn()
|
||||
})
|
||||
|
||||
form.name = 'a'.repeat(256)
|
||||
form.provider = 'huggingface'
|
||||
form.secretValue = 'secret123'
|
||||
|
||||
await handleSubmit()
|
||||
|
||||
expect(mockCreate).not.toHaveBeenCalled()
|
||||
expect(errors.name).toBe('secrets.errors.nameTooLong')
|
||||
})
|
||||
|
||||
it('requires provider in create mode', async () => {
|
||||
const visible = ref(true)
|
||||
const { form, errors, handleSubmit } = useSecretForm({
|
||||
mode: 'create',
|
||||
existingProviders: () => [],
|
||||
visible,
|
||||
onSaved: vi.fn()
|
||||
})
|
||||
|
||||
form.name = 'My Secret'
|
||||
form.provider = null
|
||||
form.secretValue = 'secret123'
|
||||
|
||||
await handleSubmit()
|
||||
|
||||
expect(mockCreate).not.toHaveBeenCalled()
|
||||
expect(errors.provider).toBe('secrets.errors.providerRequired')
|
||||
})
|
||||
|
||||
it('requires secret value in create mode', async () => {
|
||||
const visible = ref(true)
|
||||
const { form, errors, handleSubmit } = useSecretForm({
|
||||
mode: 'create',
|
||||
existingProviders: () => [],
|
||||
visible,
|
||||
onSaved: vi.fn()
|
||||
})
|
||||
|
||||
form.name = 'My Secret'
|
||||
form.provider = 'huggingface'
|
||||
form.secretValue = ''
|
||||
|
||||
await handleSubmit()
|
||||
|
||||
expect(mockCreate).not.toHaveBeenCalled()
|
||||
expect(errors.secretValue).toBe('secrets.errors.secretValueRequired')
|
||||
})
|
||||
|
||||
it('allows empty secret value in edit mode', async () => {
|
||||
const visible = ref(false)
|
||||
const secret = createMockSecret()
|
||||
mockUpdate.mockResolvedValue({})
|
||||
const { form, handleSubmit } = useSecretForm({
|
||||
mode: 'edit',
|
||||
secret: () => secret,
|
||||
existingProviders: () => [],
|
||||
visible,
|
||||
onSaved: vi.fn()
|
||||
})
|
||||
|
||||
visible.value = true
|
||||
await Promise.resolve()
|
||||
|
||||
form.name = 'Updated Name'
|
||||
form.secretValue = ''
|
||||
|
||||
await handleSubmit()
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires provider in edit mode', async () => {
|
||||
const visible = ref(true)
|
||||
const secret = createMockSecret({ provider: undefined })
|
||||
const { form, errors, handleSubmit } = useSecretForm({
|
||||
mode: 'edit',
|
||||
secret: () => secret,
|
||||
existingProviders: () => [],
|
||||
visible,
|
||||
onSaved: vi.fn()
|
||||
})
|
||||
|
||||
form.name = 'Updated Name'
|
||||
form.provider = null
|
||||
|
||||
await handleSubmit()
|
||||
|
||||
expect(mockUpdate).not.toHaveBeenCalled()
|
||||
expect(errors.provider).toBe('secrets.errors.providerRequired')
|
||||
})
|
||||
})
|
||||
|
||||
describe('providerOptions', () => {
|
||||
it('marks existing providers as disabled', () => {
|
||||
const visible = ref(true)
|
||||
const { providerOptions } = useSecretForm({
|
||||
mode: 'create',
|
||||
existingProviders: () => ['huggingface'],
|
||||
visible,
|
||||
onSaved: vi.fn()
|
||||
})
|
||||
|
||||
const huggingface = providerOptions.value.find(
|
||||
(o) => o.value === 'huggingface'
|
||||
)
|
||||
const civitai = providerOptions.value.find((o) => o.value === 'civitai')
|
||||
|
||||
expect(huggingface?.disabled).toBe(true)
|
||||
expect(civitai?.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('updates disabled state when existingProviders changes', () => {
|
||||
const visible = ref(true)
|
||||
const existingProviders = ref<SecretProvider[]>(['huggingface'])
|
||||
const { providerOptions } = useSecretForm({
|
||||
mode: 'create',
|
||||
existingProviders: () => existingProviders.value,
|
||||
visible,
|
||||
onSaved: vi.fn()
|
||||
})
|
||||
|
||||
expect(
|
||||
providerOptions.value.find((o) => o.value === 'huggingface')?.disabled
|
||||
).toBe(true)
|
||||
|
||||
existingProviders.value = []
|
||||
|
||||
expect(
|
||||
providerOptions.value.find((o) => o.value === 'huggingface')?.disabled
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleSubmit', () => {
|
||||
it('calls create API with correct payload', async () => {
|
||||
const visible = ref(true)
|
||||
const onSaved = vi.fn()
|
||||
mockCreate.mockResolvedValue({})
|
||||
|
||||
const { form, handleSubmit } = useSecretForm({
|
||||
mode: 'create',
|
||||
existingProviders: () => [],
|
||||
visible,
|
||||
onSaved
|
||||
})
|
||||
|
||||
form.name = 'My Secret'
|
||||
form.provider = 'civitai'
|
||||
form.secretValue = 'secret123'
|
||||
|
||||
await handleSubmit()
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
name: 'My Secret',
|
||||
secret_value: 'secret123',
|
||||
provider: 'civitai'
|
||||
})
|
||||
expect(onSaved).toHaveBeenCalled()
|
||||
expect(visible.value).toBe(false)
|
||||
})
|
||||
|
||||
it('calls update API with correct payload', async () => {
|
||||
const visible = ref(false)
|
||||
const onSaved = vi.fn()
|
||||
const secret = createMockSecret({ id: 'secret-123' })
|
||||
mockUpdate.mockResolvedValue({})
|
||||
|
||||
const { form, handleSubmit } = useSecretForm({
|
||||
mode: 'edit',
|
||||
secret: () => secret,
|
||||
existingProviders: () => [],
|
||||
visible,
|
||||
onSaved
|
||||
})
|
||||
|
||||
visible.value = true
|
||||
await Promise.resolve()
|
||||
|
||||
form.name = 'Updated Name'
|
||||
form.secretValue = 'newvalue'
|
||||
|
||||
await handleSubmit()
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalledWith('secret-123', {
|
||||
name: 'Updated Name',
|
||||
secret_value: 'newvalue'
|
||||
})
|
||||
expect(onSaved).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('omits secret_value in update if empty', async () => {
|
||||
const visible = ref(false)
|
||||
const secret = createMockSecret({ id: 'secret-123' })
|
||||
mockUpdate.mockResolvedValue({})
|
||||
|
||||
const { form, handleSubmit } = useSecretForm({
|
||||
mode: 'edit',
|
||||
secret: () => secret,
|
||||
existingProviders: () => [],
|
||||
visible,
|
||||
onSaved: vi.fn()
|
||||
})
|
||||
|
||||
visible.value = true
|
||||
await Promise.resolve()
|
||||
|
||||
form.name = 'Updated Name'
|
||||
form.secretValue = ''
|
||||
|
||||
await handleSubmit()
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalledWith('secret-123', {
|
||||
name: 'Updated Name'
|
||||
})
|
||||
})
|
||||
|
||||
it('sets apiError on API failure', async () => {
|
||||
const { SecretsApiError } = await import('../api/secretsApi')
|
||||
const visible = ref(true)
|
||||
mockCreate.mockRejectedValue(
|
||||
new SecretsApiError('Duplicate', 400, 'DUPLICATE_NAME')
|
||||
)
|
||||
|
||||
const { form, apiError, handleSubmit } = useSecretForm({
|
||||
mode: 'create',
|
||||
existingProviders: () => [],
|
||||
visible,
|
||||
onSaved: vi.fn()
|
||||
})
|
||||
|
||||
form.name = 'My Secret'
|
||||
form.provider = 'huggingface'
|
||||
form.secretValue = 'secret123'
|
||||
|
||||
await handleSubmit()
|
||||
|
||||
expect(apiError.value).toBe('secrets.errors.duplicateName')
|
||||
expect(visible.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('form reset on visible', () => {
|
||||
it('resets to empty state in create mode when visible becomes true', async () => {
|
||||
const visible = ref(false)
|
||||
const { form, errors } = useSecretForm({
|
||||
mode: 'create',
|
||||
existingProviders: () => [],
|
||||
visible,
|
||||
onSaved: vi.fn()
|
||||
})
|
||||
|
||||
form.name = 'Some Name'
|
||||
form.secretValue = 'some value'
|
||||
errors.name = 'some error'
|
||||
|
||||
visible.value = true
|
||||
await Promise.resolve()
|
||||
|
||||
expect(form.name).toBe('')
|
||||
expect(form.secretValue).toBe('')
|
||||
expect(form.provider).toBeNull()
|
||||
expect(errors.name).toBe('')
|
||||
})
|
||||
|
||||
it('resets to secret values in edit mode when visible becomes true', async () => {
|
||||
const visible = ref(false)
|
||||
const secret = createMockSecret({
|
||||
name: 'Original Name',
|
||||
provider: 'civitai'
|
||||
})
|
||||
const { form } = useSecretForm({
|
||||
mode: 'edit',
|
||||
secret: () => secret,
|
||||
existingProviders: () => [],
|
||||
visible,
|
||||
onSaved: vi.fn()
|
||||
})
|
||||
|
||||
form.name = 'Changed Name'
|
||||
|
||||
visible.value = true
|
||||
await Promise.resolve()
|
||||
|
||||
expect(form.name).toBe('Original Name')
|
||||
expect(form.provider).toBe('civitai')
|
||||
expect(form.secretValue).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
171
src/platform/secrets/composables/useSecretForm.ts
Normal file
171
src/platform/secrets/composables/useSecretForm.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { whenever } from '@vueuse/core'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import { computed, reactive, ref, toValue } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { createSecret, SecretsApiError, updateSecret } from '../api/secretsApi'
|
||||
import { SECRET_PROVIDERS } from '../providers'
|
||||
import type { SecretErrorCode, SecretMetadata, SecretProvider } from '../types'
|
||||
|
||||
interface SecretFormState {
|
||||
name: string
|
||||
secretValue: string
|
||||
provider: SecretProvider | null
|
||||
}
|
||||
|
||||
interface SecretFormErrors {
|
||||
name: string
|
||||
secretValue: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
interface ProviderOption {
|
||||
label: string
|
||||
value: SecretProvider
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
interface UseSecretFormOptions {
|
||||
mode: 'create' | 'edit'
|
||||
secret?: MaybeRefOrGetter<SecretMetadata | undefined>
|
||||
existingProviders: MaybeRefOrGetter<SecretProvider[]>
|
||||
visible: { value: boolean }
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
export function useSecretForm(options: UseSecretFormOptions) {
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
mode,
|
||||
secret: secretRef,
|
||||
existingProviders,
|
||||
visible,
|
||||
onSaved
|
||||
} = options
|
||||
|
||||
const loading = ref(false)
|
||||
const apiErrorCode = ref<SecretErrorCode | null>(null)
|
||||
const apiErrorMessage = ref<string | null>(null)
|
||||
|
||||
const form = reactive<SecretFormState>({
|
||||
name: '',
|
||||
secretValue: '',
|
||||
provider: null
|
||||
})
|
||||
|
||||
const errors = reactive<SecretFormErrors>({
|
||||
name: '',
|
||||
secretValue: '',
|
||||
provider: ''
|
||||
})
|
||||
|
||||
const providerOptions = computed<ProviderOption[]>(() =>
|
||||
SECRET_PROVIDERS.map((p) => ({
|
||||
label: p.label,
|
||||
value: p.value,
|
||||
disabled:
|
||||
mode === 'edit' ? false : toValue(existingProviders).includes(p.value)
|
||||
}))
|
||||
)
|
||||
|
||||
const apiError = computed(() => {
|
||||
if (!apiErrorCode.value && !apiErrorMessage.value) return null
|
||||
switch (apiErrorCode.value) {
|
||||
case 'DUPLICATE_NAME':
|
||||
return t('secrets.errors.duplicateName')
|
||||
case 'DUPLICATE_PROVIDER':
|
||||
return t('secrets.errors.duplicateProvider')
|
||||
default:
|
||||
return apiErrorMessage.value
|
||||
}
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
const secret = toValue(secretRef)
|
||||
if (mode === 'edit' && secret) {
|
||||
form.name = secret.name
|
||||
form.provider = secret.provider ?? null
|
||||
form.secretValue = ''
|
||||
} else {
|
||||
form.name = ''
|
||||
form.secretValue = ''
|
||||
form.provider = null
|
||||
}
|
||||
errors.name = ''
|
||||
errors.secretValue = ''
|
||||
errors.provider = ''
|
||||
apiErrorCode.value = null
|
||||
apiErrorMessage.value = null
|
||||
}
|
||||
|
||||
whenever(() => visible.value, resetForm)
|
||||
|
||||
function validate(): boolean {
|
||||
errors.name = ''
|
||||
errors.secretValue = ''
|
||||
errors.provider = ''
|
||||
|
||||
if (!form.name.trim()) {
|
||||
errors.name = t('secrets.errors.nameRequired')
|
||||
return false
|
||||
}
|
||||
if (form.name.length > 255) {
|
||||
errors.name = t('secrets.errors.nameTooLong')
|
||||
return false
|
||||
}
|
||||
if (!form.provider) {
|
||||
errors.provider = t('secrets.errors.providerRequired')
|
||||
return false
|
||||
}
|
||||
if (mode === 'create' && !form.secretValue) {
|
||||
errors.secretValue = t('secrets.errors.secretValueRequired')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!validate()) return
|
||||
|
||||
loading.value = true
|
||||
apiErrorCode.value = null
|
||||
apiErrorMessage.value = null
|
||||
|
||||
try {
|
||||
const secret = toValue(secretRef)
|
||||
if (mode === 'create') {
|
||||
await createSecret({
|
||||
name: form.name.trim(),
|
||||
secret_value: form.secretValue,
|
||||
provider: form.provider!
|
||||
})
|
||||
} else if (secret) {
|
||||
const updatePayload: { name: string; secret_value?: string } = {
|
||||
name: form.name.trim()
|
||||
}
|
||||
if (form.secretValue) {
|
||||
updatePayload.secret_value = form.secretValue
|
||||
}
|
||||
await updateSecret(secret.id, updatePayload)
|
||||
}
|
||||
onSaved()
|
||||
visible.value = false
|
||||
} catch (err) {
|
||||
if (err instanceof SecretsApiError) {
|
||||
apiErrorCode.value = err.code ?? null
|
||||
apiErrorMessage.value = err.message
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
form,
|
||||
errors,
|
||||
loading,
|
||||
apiError,
|
||||
providerOptions,
|
||||
handleSubmit
|
||||
}
|
||||
}
|
||||
154
src/platform/secrets/composables/useSecrets.test.ts
Normal file
154
src/platform/secrets/composables/useSecrets.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { SecretMetadata } from '../types'
|
||||
import { useSecrets } from './useSecrets'
|
||||
|
||||
const mockAdd = vi.fn()
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ add: mockAdd })
|
||||
}))
|
||||
|
||||
const mockListSecrets = vi.fn()
|
||||
const mockDeleteSecret = vi.fn()
|
||||
|
||||
vi.mock('../api/secretsApi', () => ({
|
||||
listSecrets: () => mockListSecrets(),
|
||||
deleteSecret: (id: string) => mockDeleteSecret(id),
|
||||
SecretsApiError: class SecretsApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status?: number,
|
||||
public readonly code?: string
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'SecretsApiError'
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
function createMockSecret(
|
||||
overrides: Partial<SecretMetadata> = {}
|
||||
): SecretMetadata {
|
||||
return {
|
||||
id: 'secret-1',
|
||||
name: 'Test Secret',
|
||||
provider: 'huggingface',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('useSecrets', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('fetchSecrets', () => {
|
||||
it('fetches and populates secrets list', async () => {
|
||||
const mockSecrets = [
|
||||
createMockSecret({ id: '1', name: 'Secret 1' }),
|
||||
createMockSecret({ id: '2', name: 'Secret 2' })
|
||||
]
|
||||
mockListSecrets.mockResolvedValue(mockSecrets)
|
||||
|
||||
const { secrets, loading, fetchSecrets } = useSecrets()
|
||||
|
||||
expect(loading.value).toBe(false)
|
||||
const fetchPromise = fetchSecrets()
|
||||
expect(loading.value).toBe(true)
|
||||
|
||||
await fetchPromise
|
||||
|
||||
expect(loading.value).toBe(false)
|
||||
expect(secrets.value).toEqual(mockSecrets)
|
||||
})
|
||||
|
||||
it('shows error toast on API failure', async () => {
|
||||
const { SecretsApiError } = await import('../api/secretsApi')
|
||||
mockListSecrets.mockRejectedValue(
|
||||
new SecretsApiError('Network error', 500)
|
||||
)
|
||||
|
||||
const { secrets, fetchSecrets } = useSecrets()
|
||||
|
||||
await fetchSecrets()
|
||||
|
||||
expect(secrets.value).toEqual([])
|
||||
expect(mockAdd).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'g.error',
|
||||
detail: 'Network error',
|
||||
life: 5000
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteSecret', () => {
|
||||
it('deletes secret and removes from list', async () => {
|
||||
const secretToDelete = createMockSecret({ id: '1' })
|
||||
const remainingSecret = createMockSecret({ id: '2' })
|
||||
mockListSecrets.mockResolvedValue([secretToDelete, remainingSecret])
|
||||
mockDeleteSecret.mockResolvedValue(undefined)
|
||||
|
||||
const { secrets, operatingSecretId, fetchSecrets, deleteSecret } =
|
||||
useSecrets()
|
||||
|
||||
await fetchSecrets()
|
||||
expect(secrets.value).toHaveLength(2)
|
||||
|
||||
const deletePromise = deleteSecret(secretToDelete)
|
||||
expect(operatingSecretId.value).toBe('1')
|
||||
|
||||
await deletePromise
|
||||
|
||||
expect(operatingSecretId.value).toBe(null)
|
||||
expect(secrets.value).toHaveLength(1)
|
||||
expect(secrets.value[0].id).toBe('2')
|
||||
expect(mockDeleteSecret).toHaveBeenCalledWith('1')
|
||||
})
|
||||
|
||||
it('shows error toast on delete failure', async () => {
|
||||
const { SecretsApiError } = await import('../api/secretsApi')
|
||||
const secret = createMockSecret()
|
||||
mockListSecrets.mockResolvedValue([secret])
|
||||
mockDeleteSecret.mockRejectedValue(
|
||||
new SecretsApiError('Delete failed', 500)
|
||||
)
|
||||
|
||||
const { secrets, fetchSecrets, deleteSecret } = useSecrets()
|
||||
|
||||
await fetchSecrets()
|
||||
await deleteSecret(secret)
|
||||
|
||||
expect(secrets.value).toHaveLength(1)
|
||||
expect(mockAdd).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'g.error',
|
||||
detail: 'Delete failed',
|
||||
life: 5000
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('existingProviders', () => {
|
||||
it('returns list of providers from secrets', async () => {
|
||||
mockListSecrets.mockResolvedValue([
|
||||
createMockSecret({ id: '1', provider: 'huggingface' }),
|
||||
createMockSecret({ id: '2', provider: 'civitai' }),
|
||||
createMockSecret({ id: '3', provider: undefined })
|
||||
])
|
||||
|
||||
const { existingProviders, fetchSecrets } = useSecrets()
|
||||
|
||||
await fetchSecrets()
|
||||
|
||||
expect(existingProviders.value).toEqual(['huggingface', 'civitai'])
|
||||
})
|
||||
})
|
||||
})
|
||||
88
src/platform/secrets/composables/useSecrets.ts
Normal file
88
src/platform/secrets/composables/useSecrets.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
import {
|
||||
deleteSecret as deleteSecretApi,
|
||||
listSecrets,
|
||||
SecretsApiError
|
||||
} from '../api/secretsApi'
|
||||
import type { SecretMetadata, SecretProvider } from '../types'
|
||||
|
||||
export function useSecrets() {
|
||||
const { t } = useI18n()
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const secrets = ref<SecretMetadata[]>([])
|
||||
const operatingSecretId = ref<string | null>(null)
|
||||
|
||||
const existingProviders = computed<SecretProvider[]>(() =>
|
||||
secrets.value
|
||||
.map((s) => s.provider)
|
||||
.filter((p): p is SecretProvider => p !== undefined)
|
||||
)
|
||||
|
||||
async function fetchSecrets() {
|
||||
loading.value = true
|
||||
try {
|
||||
secrets.value = await listSecrets()
|
||||
} catch (err) {
|
||||
if (err instanceof SecretsApiError) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: err.message,
|
||||
life: 5000
|
||||
})
|
||||
} else {
|
||||
console.error('Unexpected error fetching secrets:', err)
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('g.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSecret(secret: SecretMetadata) {
|
||||
operatingSecretId.value = secret.id
|
||||
try {
|
||||
await deleteSecretApi(secret.id)
|
||||
secrets.value = secrets.value.filter((s) => s.id !== secret.id)
|
||||
} catch (err) {
|
||||
if (err instanceof SecretsApiError) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: err.message,
|
||||
life: 5000
|
||||
})
|
||||
} else {
|
||||
console.error('Unexpected error deleting secret:', err)
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('g.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
operatingSecretId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
secrets,
|
||||
operatingSecretId,
|
||||
existingProviders,
|
||||
fetchSecrets,
|
||||
deleteSecret
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user