Files
ComfyUI_frontend/src/platform/secrets/composables/useSecretForm.ts
Luke Mino-Altherr bc19bb60fb 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>
2026-02-02 17:28:58 -08:00

172 lines
4.1 KiB
TypeScript

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
}
}