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:
Luke Mino-Altherr
2026-02-02 17:28:58 -08:00
committed by GitHub
parent 139ee32d78
commit bc19bb60fb
22 changed files with 1693 additions and 54 deletions

View 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('')
})
})
})

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

View 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'])
})
})
})

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