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

@@ -22,7 +22,8 @@ export enum ServerFeatureFlag {
HUGGINGFACE_MODEL_IMPORT_ENABLED = 'huggingface_model_import_enabled',
LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled',
ASYNC_MODEL_UPLOAD_ENABLED = 'async_model_upload_enabled',
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled'
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled',
USER_SECRETS_ENABLED = 'user_secrets_enabled'
}
/**
@@ -116,6 +117,12 @@ export function useFeatureFlags() {
remoteConfig.value.team_workspaces_enabled ??
api.getServerFeature(ServerFeatureFlag.TEAM_WORKSPACES_ENABLED, false)
)
},
get userSecretsEnabled() {
return (
remoteConfig.value.user_secrets_enabled ??
api.getServerFeature(ServerFeatureFlag.USER_SECRETS_ENABLED, false)
)
}
})

View File

@@ -1332,7 +1332,8 @@
"Execution": "Execution",
"PLY": "PLY",
"Workspace": "Workspace",
"Other": "Other"
"Other": "Other",
"Secrets": "Secrets"
},
"serverConfigItems": {
"listen": {
@@ -2517,13 +2518,34 @@
"civitaiLinkPlaceholder": "Paste link here",
"confirmModelDetails": "Confirm Model Details",
"connectionError": "Please check your connection and try again",
"errorAccessForbidden": "Access to this resource is forbidden.",
"errorConnectionRefused": "Unable to connect to the source. Please try again later.",
"errorDownloadCancelled": "Download was cancelled.",
"errorFileTooLarge": "File exceeds the maximum allowed size limit",
"errorFormatNotAllowed": "Only SafeTensor format is allowed",
"errorHttpError": "An error occurred while fetching metadata.",
"errorInternalError": "An unexpected error occurred. Please try again.",
"errorInvalidHost": "The source URL hostname could not be resolved.",
"errorInvalidUrl": "Please provide a URL.",
"errorInvalidUrlFormat": "The URL format is invalid. Please check and try again.",
"errorMetadataFetchFailed": "Failed to fetch file information from the source.",
"errorModelTypeNotSupported": "This model type is not supported",
"errorNetworkError": "A network error occurred. Please check your connection and try again.",
"errorNetworkTimeout": "Request timed out. Please try again.",
"errorRateLimited": "Too many requests. Please try again in a few minutes.",
"errorRequestCancelled": "Request was cancelled.",
"errorResourceNotFound": "The file was not found. Please check the URL and try again.",
"errorServiceUnavailable": "Service temporarily unavailable. Please try again later.",
"errorSourceServerError": "The source server is experiencing issues. Please try again later.",
"errorUnauthorized": "Please sign in to continue.",
"errorUnauthorizedSource": "This resource requires authentication. Please add your API token in settings.",
"errorUnknown": "An unexpected error occurred",
"errorUnsafePickleScan": "CivitAI detected potentially unsafe code in this file",
"errorUnsafeVirusScan": "CivitAI detected malware or suspicious content in this file",
"errorUnsupportedSource": "This URL is not supported. Only Hugging Face and Civitai URLs are allowed.",
"errorUploadFailed": "Failed to import asset. Please try again.",
"errorUserTokenAccessDenied": "Your API token does not have access to this resource. Please check your token permissions.",
"errorUserTokenInvalid": "Your stored API token is invalid or expired. Please update your token in settings.",
"failedToCreateNode": "Failed to create node. Please try again or check console for details.",
"fileFormats": "File formats",
"fileName": "File Name",
@@ -2576,6 +2598,8 @@
"upgradeToUnlockFeature": "Upgrade to unlock this feature",
"upload": "Import",
"uploadFailed": "Import failed",
"apiKeyHint": "Importing private or gated models? {link}.",
"apiKeyHintLink": "Add your API keys in Settings",
"uploadingModel": "Importing model...",
"uploadModel": "Import",
"uploadModelDescription1": "Paste a Civitai model download link to add it to your library.",
@@ -2861,5 +2885,35 @@
"hideDevOnlyDescription": "Hides nodes marked as dev-only unless dev mode is enabled",
"hideSubgraph": "Hide Subgraph Nodes",
"hideSubgraphDescription": "Temporarily hides subgraph nodes from node library and search"
},
"secrets": {
"title": "API Keys & Secrets",
"description": "Secrets are encrypted and used for sensitive data like API keys.",
"descriptionUsage": "Store your tokens here to enable downloading private and gated models from supported providers.",
"modelProviders": "Model Providers",
"addSecret": "Add Secret",
"editSecret": "Edit Secret",
"noSecrets": "No secrets stored. Add your first API key to get started.",
"name": "Name",
"namePlaceholder": "e.g., My API Key",
"provider": "Provider",
"providerHint": "Optional. Selecting a provider enables automatic token usage.",
"secretValue": "Secret Value",
"secretValuePlaceholder": "Enter your API key",
"secretValuePlaceholderEdit": "Enter new value to change",
"secretValueHint": "This value will be encrypted and cannot be viewed again.",
"secretValueHintEdit": "Leave blank to keep the current value.",
"createdAt": "Created {date}",
"lastUsed": "Last used {date}",
"deleteConfirmTitle": "Delete Secret",
"deleteConfirmMessage": "Are you sure you want to delete \"{name}\"? This action cannot be undone.",
"errors": {
"nameRequired": "Name is required",
"nameTooLong": "Name must be 255 characters or less",
"providerRequired": "Provider is required",
"secretValueRequired": "Secret value is required",
"duplicateName": "A secret with this name already exists",
"duplicateProvider": "A secret for this provider already exists"
}
}
}

View File

@@ -83,7 +83,7 @@
<script setup lang="ts">
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
const { result } = defineProps<{
defineProps<{
result: 'processing' | 'success' | 'error'
error?: string
metadata?: AssetMetadata

View File

@@ -45,19 +45,13 @@
</div>
<div class="flex flex-col gap-2">
<div class="relative">
<InputText
v-model="url"
autofocus
:placeholder="$t('assetBrowser.genericLinkPlaceholder')"
class="w-full border-0 bg-secondary-background p-4 pr-10"
data-attr="upload-model-step1-url-input"
/>
<i
v-if="isValidUrl"
class="icon-[lucide--circle-check-big] absolute top-1/2 right-3 size-5 -translate-y-1/2 text-green-500"
/>
</div>
<InputText
v-model="url"
autofocus
:placeholder="$t('assetBrowser.genericLinkPlaceholder')"
class="w-full border-0 bg-secondary-background p-4"
data-attr="upload-model-step1-url-input"
/>
<p v-if="error" class="text-sm text-error">
{{ error }}
</p>
@@ -73,8 +67,24 @@
</div>
</div>
<div class="text-sm text-muted">
{{ $t('assetBrowser.uploadModelHelpFooterText') }}
<div class="flex flex-col gap-6 text-sm text-muted-foreground">
<div v-if="showSecretsHint">
<i18n-t keypath="assetBrowser.apiKeyHint" tag="span">
<template #link>
<Button
variant="textonly"
size="unset"
class="text-muted-foreground underline p-0"
@click="openSecretsSettings"
>
{{ $t('assetBrowser.apiKeyHintLink') }}
</Button>
</template>
</i18n-t>
</div>
<div>
{{ $t('assetBrowser.uploadModelHelpFooterText') }}
</div>
</div>
</div>
</template>
@@ -83,12 +93,18 @@
import InputText from 'primevue/inputtext'
import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { civitaiImportSource } from '@/platform/assets/importSources/civitaiImportSource'
import { huggingfaceImportSource } from '@/platform/assets/importSources/huggingfaceImportSource'
import { validateSourceUrl } from '@/platform/assets/utils/importSourceUtil'
import { useDialogService } from '@/services/dialogService'
const { flags } = useFeatureFlags()
const dialogService = useDialogService()
const showSecretsHint = computed(() => flags.userSecretsEnabled)
function openSecretsSettings() {
dialogService.showSettingsDialog('secrets')
}
const props = defineProps<{
modelValue: string
@@ -104,14 +120,6 @@ const url = computed({
set: (value: string) => emit('update:modelValue', value)
})
const importSources = [civitaiImportSource, huggingfaceImportSource]
const isValidUrl = computed(() => {
const trimmedUrl = url.value.trim()
if (!trimmedUrl) return false
return importSources.some((source) => validateSourceUrl(trimmedUrl, source))
})
const civitaiIcon = '/assets/images/civitai.svg'
const civitaiUrl = 'https://civitai.com/models'
const huggingFaceIcon = '/assets/images/hf-logo.svg'

View File

@@ -38,19 +38,13 @@
}}</span>
</template>
</i18n-t>
<div class="relative">
<InputText
v-model="url"
autofocus
:placeholder="$t('assetBrowser.civitaiLinkPlaceholder')"
class="w-full border-0 bg-secondary-background p-4 pr-10"
data-attr="upload-model-step1-url-input"
/>
<i
v-if="isValidUrl"
class="icon-[lucide--circle-check-big] absolute top-1/2 right-3 size-5 -translate-y-1/2 text-green-500"
/>
</div>
<InputText
v-model="url"
autofocus
:placeholder="$t('assetBrowser.civitaiLinkPlaceholder')"
class="w-full border-0 bg-secondary-background p-4"
data-attr="upload-model-step1-url-input"
/>
<p v-if="error" class="text-sm text-error">
{{ error }}
</p>
@@ -73,6 +67,21 @@
</a>
</template>
</i18n-t>
<div v-if="showSecretsHint" class="text-sm text-muted">
<i18n-t keypath="assetBrowser.apiKeyHint" tag="span">
<template #link>
<Button
variant="textonly"
size="unset"
class="text-muted underline p-0"
@click="openSecretsSettings"
>
{{ $t('assetBrowser.apiKeyHintLink') }}
</Button>
</template>
</i18n-t>
</div>
</div>
</div>
</template>
@@ -81,21 +90,22 @@
import InputText from 'primevue/inputtext'
import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { civitaiImportSource } from '@/platform/assets/importSources/civitaiImportSource'
import { validateSourceUrl } from '@/platform/assets/utils/importSourceUtil'
import { useDialogService } from '@/services/dialogService'
const { flags } = useFeatureFlags()
const dialogService = useDialogService()
const showSecretsHint = computed(() => flags.userSecretsEnabled)
function openSecretsSettings() {
dialogService.showSettingsDialog('secrets')
}
defineProps<{
error?: string
}>()
const url = defineModel<string>({ required: true })
const isValidUrl = computed(() => {
const trimmedUrl = url.value.trim()
if (!trimmedUrl) return false
return validateSourceUrl(trimmedUrl, civitaiImportSource)
})
</script>

View File

@@ -69,9 +69,9 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
}
)
// Validation
// Validation - only enable Continue when URL matches a supported source
const canFetchMetadata = computed(() => {
return wizardData.value.url.trim().length > 0
return detectedSource.value !== null
})
const canUploadModel = computed(() => {

View File

@@ -36,6 +36,7 @@ interface AssetRequestOptions extends PaginationOptions {
*/
function getLocalizedErrorMessage(errorCode: string): string {
const errorMessages: Record<string, string> = {
// Validation errors
FILE_TOO_LARGE: st('assetBrowser.errorFileTooLarge', 'File too large'),
FORMAT_NOT_ALLOWED: st(
'assetBrowser.errorFormatNotAllowed',
@@ -52,6 +53,95 @@ function getLocalizedErrorMessage(errorCode: string): string {
MODEL_TYPE_NOT_SUPPORTED: st(
'assetBrowser.errorModelTypeNotSupported',
'Model type not supported'
),
// HTTP 400 - Bad Request
INVALID_URL: st('assetBrowser.errorInvalidUrl', 'Please provide a URL.'),
INVALID_URL_FORMAT: st(
'assetBrowser.errorInvalidUrlFormat',
'The URL format is invalid. Please check and try again.'
),
UNSUPPORTED_SOURCE: st(
'assetBrowser.errorUnsupportedSource',
'This URL is not supported. Only Hugging Face and Civitai URLs are allowed.'
),
// HTTP 401 - Unauthorized
UNAUTHORIZED: st(
'assetBrowser.errorUnauthorized',
'Please sign in to continue.'
),
// HTTP 422 - External Source Errors
USER_TOKEN_INVALID: st(
'assetBrowser.errorUserTokenInvalid',
'Your stored API token is invalid or expired. Please update your token in settings.'
),
USER_TOKEN_ACCESS_DENIED: st(
'assetBrowser.errorUserTokenAccessDenied',
'Your API token does not have access to this resource. Please check your token permissions.'
),
UNAUTHORIZED_SOURCE: st(
'assetBrowser.errorUnauthorizedSource',
'This resource requires authentication. Please add your API token in settings.'
),
ACCESS_FORBIDDEN: st(
'assetBrowser.errorAccessForbidden',
'Access to this resource is forbidden.'
),
RESOURCE_NOT_FOUND: st(
'assetBrowser.errorResourceNotFound',
'The file was not found. Please check the URL and try again.'
),
RATE_LIMITED: st(
'assetBrowser.errorRateLimited',
'Too many requests. Please try again in a few minutes.'
),
SOURCE_SERVER_ERROR: st(
'assetBrowser.errorSourceServerError',
'The source server is experiencing issues. Please try again later.'
),
NETWORK_TIMEOUT: st(
'assetBrowser.errorNetworkTimeout',
'Request timed out. Please try again.'
),
CONNECTION_REFUSED: st(
'assetBrowser.errorConnectionRefused',
'Unable to connect to the source. Please try again later.'
),
INVALID_HOST: st(
'assetBrowser.errorInvalidHost',
'The source URL hostname could not be resolved.'
),
NETWORK_ERROR: st(
'assetBrowser.errorNetworkError',
'A network error occurred. Please check your connection and try again.'
),
REQUEST_CANCELLED: st(
'assetBrowser.errorRequestCancelled',
'Request was cancelled.'
),
DOWNLOAD_CANCELLED: st(
'assetBrowser.errorDownloadCancelled',
'Download was cancelled.'
),
METADATA_FETCH_FAILED: st(
'assetBrowser.errorMetadataFetchFailed',
'Failed to fetch file information from the source.'
),
HTTP_ERROR: st(
'assetBrowser.errorHttpError',
'An error occurred while fetching metadata.'
),
// HTTP 500 - Internal Server Errors
SERVICE_UNAVAILABLE: st(
'assetBrowser.errorServiceUnavailable',
'Service temporarily unavailable. Please try again later.'
),
INTERNAL_ERROR: st(
'assetBrowser.errorInternalError',
'An unexpected error occurred. Please try again.'
)
}
return (

View File

@@ -43,4 +43,5 @@ export type RemoteConfig = {
linear_toggle_enabled?: boolean
async_model_upload_enabled?: boolean
team_workspaces_enabled?: boolean
user_secrets_enabled?: boolean
}

View File

@@ -0,0 +1,89 @@
import { api } from '@/scripts/api'
import type {
SecretCreateRequest,
SecretErrorCode,
SecretMetadata,
SecretUpdateRequest
} from '../types'
import { SECRET_ERROR_CODES } from '../types'
interface ListSecretsResponse {
data: SecretMetadata[]
}
interface ErrorResponse {
message?: string
code?: string
}
export class SecretsApiError extends Error {
constructor(
message: string,
public readonly status?: number,
public readonly code?: SecretErrorCode
) {
super(message)
this.name = 'SecretsApiError'
}
}
async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
let errorData: ErrorResponse = {}
try {
errorData = await response.json()
} catch {
// Response body is not JSON
}
const code = SECRET_ERROR_CODES.includes(
errorData.code as (typeof SECRET_ERROR_CODES)[number]
)
? (errorData.code as SecretErrorCode)
: undefined
throw new SecretsApiError(
errorData.message ?? response.statusText,
response.status,
code
)
}
return response.json()
}
export async function listSecrets(): Promise<SecretMetadata[]> {
const response = await api.fetchApi('/secrets')
const data = await handleResponse<ListSecretsResponse>(response)
return data.data
}
export async function createSecret(
payload: SecretCreateRequest
): Promise<SecretMetadata> {
const response = await api.fetchApi('/secrets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
return handleResponse<SecretMetadata>(response)
}
export async function updateSecret(
id: string,
payload: SecretUpdateRequest
): Promise<SecretMetadata> {
const response = await api.fetchApi(`/secrets/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
return handleResponse<SecretMetadata>(response)
}
export async function deleteSecret(id: string): Promise<void> {
const response = await api.fetchApi(`/secrets/${id}`, {
method: 'DELETE'
})
if (!response.ok) {
await handleResponse<void>(response)
}
}

View File

@@ -0,0 +1,132 @@
<template>
<Dialog
v-model:visible="visible"
:header="
mode === 'create' ? $t('secrets.addSecret') : $t('secrets.editSecret')
"
modal
class="w-full max-w-md"
>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<div class="flex flex-col gap-1">
<label for="secret-provider" class="text-sm font-medium">
{{ $t('secrets.provider') }}
</label>
<Select v-model="form.provider" :disabled="mode === 'edit'">
<SelectTrigger id="secret-provider" class="w-full">
<SelectValue :placeholder="$t('g.none')" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in providerOptions"
:key="option.value || 'none'"
:value="option.value"
:disabled="option.disabled"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
<small v-if="errors.provider" class="text-red-500">
{{ errors.provider }}
</small>
</div>
<div class="flex flex-col gap-1">
<label for="secret-name" class="text-sm font-medium">
{{ $t('secrets.name') }}
</label>
<InputText
id="secret-name"
v-model="form.name"
:placeholder="$t('secrets.namePlaceholder')"
:class="{ 'p-invalid': errors.name }"
/>
<small v-if="errors.name" class="text-red-500">{{ errors.name }}</small>
</div>
<div class="flex flex-col gap-1">
<label for="secret-value" class="text-sm font-medium">
{{ $t('secrets.secretValue') }}
</label>
<Password
id="secret-value"
v-model="form.secretValue"
:placeholder="
mode === 'edit'
? $t('secrets.secretValuePlaceholderEdit')
: $t('secrets.secretValuePlaceholder')
"
:feedback="false"
toggle-mask
fluid
:class="{ 'p-invalid': errors.secretValue }"
/>
<small v-if="errors.secretValue" class="text-red-500">
{{ errors.secretValue }}
</small>
<small v-else class="text-muted">
{{
mode === 'edit'
? $t('secrets.secretValueHintEdit')
: $t('secrets.secretValueHint')
}}
</small>
</div>
<span v-if="apiError" class="text-sm text-destructive">
{{ apiError }}
</span>
<div class="flex justify-end gap-2 pt-2">
<Button variant="secondary" type="button" @click="visible = false">
{{ $t('g.cancel') }}
</Button>
<Button type="submit" :loading="loading">
{{ $t('g.save') }}
</Button>
</div>
</form>
</Dialog>
</template>
<script setup lang="ts">
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import Button from '@/components/ui/button/Button.vue'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import { useSecretForm } from '../composables/useSecretForm'
import type { SecretMetadata, SecretProvider } from '../types'
const {
secret,
existingProviders = [],
mode = 'create'
} = defineProps<{
secret?: SecretMetadata
existingProviders?: SecretProvider[]
mode?: 'create' | 'edit'
}>()
const visible = defineModel<boolean>('visible', { default: false })
const emit = defineEmits<{
saved: []
}>()
const { form, errors, loading, apiError, providerOptions, handleSubmit } =
useSecretForm({
mode,
secret: () => secret,
existingProviders: () => existingProviders,
visible,
onSaved: () => emit('saved')
})
</script>

View File

@@ -0,0 +1,178 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import type { SecretMetadata } from '../types'
import SecretListItem from './SecretListItem.vue'
vi.mock('../providers', () => ({
getProviderLabel: (provider: string | undefined) => {
if (provider === 'huggingface') return 'HuggingFace'
if (provider === 'civitai') return 'Civitai'
return ''
},
getProviderLogo: () => undefined
}))
function createMockSecret(
overrides: Partial<SecretMetadata> = {}
): SecretMetadata {
return {
id: 'secret-1',
name: 'Test Secret',
provider: 'huggingface',
created_at: '2024-01-15T10:00:00Z',
updated_at: '2024-01-15T10:00:00Z',
...overrides
}
}
function mountComponent(props: {
secret: SecretMetadata
loading?: boolean
disabled?: boolean
}) {
return mount(SecretListItem, {
props,
global: {
stubs: {
Button: {
template:
'<button :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
props: ['disabled', 'variant', 'size', 'aria-label']
}
},
directives: {
tooltip: () => {}
},
mocks: {
$t: (key: string, params?: object) =>
`${key}${params ? JSON.stringify(params) : ''}`
}
}
})
}
describe('SecretListItem', () => {
describe('rendering', () => {
it('displays secret name', () => {
const secret = createMockSecret({ name: 'My API Key' })
const wrapper = mountComponent({ secret })
expect(wrapper.text()).toContain('My API Key')
})
it('displays provider label when provider exists', () => {
const secret = createMockSecret({ provider: 'huggingface' })
const wrapper = mountComponent({ secret })
expect(wrapper.text()).toContain('HuggingFace')
})
it('displays Civitai provider label', () => {
const secret = createMockSecret({ provider: 'civitai' })
const wrapper = mountComponent({ secret })
expect(wrapper.text()).toContain('Civitai')
})
it('hides provider badge when no provider', () => {
const secret = createMockSecret({ provider: undefined })
const wrapper = mountComponent({ secret })
expect(wrapper.text()).not.toContain('HuggingFace')
expect(wrapper.text()).not.toContain('Civitai')
})
it('displays created date', () => {
const secret = createMockSecret({ created_at: '2024-01-15T10:00:00Z' })
const wrapper = mountComponent({ secret })
expect(wrapper.text()).toContain('secrets.createdAt')
})
it('displays last used date when available', () => {
const secret = createMockSecret({ last_used_at: '2024-01-20T10:00:00Z' })
const wrapper = mountComponent({ secret })
expect(wrapper.text()).toContain('secrets.lastUsed')
})
it('hides last used when not available', () => {
const secret = createMockSecret({ last_used_at: undefined })
const wrapper = mountComponent({ secret })
expect(wrapper.text()).not.toContain('secrets.lastUsed')
})
})
describe('loading state', () => {
it('shows spinner when loading', () => {
const secret = createMockSecret()
const wrapper = mountComponent({ secret, loading: true })
expect(wrapper.find('.pi-spinner').exists()).toBe(true)
})
it('hides action buttons when loading', () => {
const secret = createMockSecret()
const wrapper = mountComponent({ secret, loading: true })
expect(wrapper.find('.pi-pen-to-square').exists()).toBe(false)
expect(wrapper.find('.pi-trash').exists()).toBe(false)
})
it('shows action buttons when not loading', () => {
const secret = createMockSecret()
const wrapper = mountComponent({ secret, loading: false })
expect(wrapper.find('.pi-pen-to-square').exists()).toBe(true)
expect(wrapper.find('.pi-trash').exists()).toBe(true)
})
})
describe('disabled state', () => {
it('disables buttons when disabled prop is true', () => {
const secret = createMockSecret()
const wrapper = mountComponent({ secret, disabled: true })
const buttons = wrapper.findAll('button')
buttons.forEach((button) => {
expect(button.attributes('disabled')).toBeDefined()
})
})
it('enables buttons when disabled prop is false', () => {
const secret = createMockSecret()
const wrapper = mountComponent({ secret, disabled: false })
const buttons = wrapper.findAll('button')
buttons.forEach((button) => {
expect(button.attributes('disabled')).toBeUndefined()
})
})
})
describe('events', () => {
it('emits edit event when edit button clicked', async () => {
const secret = createMockSecret()
const wrapper = mountComponent({ secret })
const editButton = wrapper.findAll('button')[0]
await editButton.trigger('click')
expect(wrapper.emitted('edit')).toBeDefined()
expect(wrapper.emitted('edit')!.length).toBeGreaterThanOrEqual(1)
})
it('emits delete event when delete button clicked', async () => {
const secret = createMockSecret()
const wrapper = mountComponent({ secret })
const deleteButton = wrapper.findAll('button')[1]
await deleteButton.trigger('click')
expect(wrapper.emitted('delete')).toBeDefined()
expect(wrapper.emitted('delete')!.length).toBeGreaterThanOrEqual(1)
})
})
})

View File

@@ -0,0 +1,91 @@
<template>
<div
class="flex items-center justify-between rounded-lg border border-border-default bg-base-raised-surface p-4"
>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<span class="font-medium text-base-foreground">{{ secret.name }}</span>
<img
v-if="providerLogo"
:src="providerLogo"
:alt="providerLabel"
class="h-5 w-5"
/>
<span
v-else-if="secret.provider"
class="rounded bg-base-surface px-2 py-0.5 text-xs text-muted"
>
{{ providerLabel }}
</span>
</div>
<div class="flex gap-3 text-xs text-muted">
<span>{{ $t('secrets.createdAt', { date: createdDate }) }}</span>
<span v-if="secret.last_used_at">
{{ $t('secrets.lastUsed', { date: lastUsedDate }) }}
</span>
</div>
</div>
<div class="flex items-center gap-2">
<i v-if="loading" class="pi pi-spinner pi-spin text-muted" />
<template v-else>
<Button
v-tooltip="{ value: $t('g.edit'), showDelay: 300 }"
variant="muted-textonly"
size="icon-sm"
:aria-label="$t('g.edit')"
:disabled="disabled"
@click="emit('edit')"
>
<i class="pi pi-pen-to-square" />
</Button>
<Button
v-tooltip="{ value: $t('g.delete'), showDelay: 300 }"
variant="muted-textonly"
size="icon-sm"
:aria-label="$t('g.delete')"
:disabled="disabled"
@click="emit('delete')"
>
<i class="pi pi-trash" />
</Button>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { getProviderLabel, getProviderLogo } from '../providers'
import type { SecretMetadata } from '../types'
const {
secret,
loading = false,
disabled = false
} = defineProps<{
secret: SecretMetadata
loading?: boolean
disabled?: boolean
}>()
const emit = defineEmits<{
edit: []
delete: []
}>()
const providerLabel = computed(() => getProviderLabel(secret.provider))
const providerLogo = computed(() => getProviderLogo(secret.provider))
function formatDateString(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString()
}
const createdDate = computed(() => formatDateString(secret.created_at))
const lastUsedDate = computed(() =>
secret.last_used_at ? formatDateString(secret.last_used_at) : ''
)
</script>

View File

@@ -0,0 +1,119 @@
<template>
<TabPanel value="Secrets" class="h-full">
<div class="flex h-full flex-col">
<div>
<h2 class="text-2xl font-bold">{{ $t('secrets.title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ $t('secrets.description') }}</p>
<p class="mt-1 text-sm text-muted">
{{ $t('secrets.descriptionUsage') }}
</p>
</div>
<Divider class="my-4" />
<div class="flex items-center justify-between my-4">
<h3 class="text-lg font-semibold my-0">
{{ $t('secrets.modelProviders') }}
</h3>
<Button @click="openCreateDialog">
<i class="pi pi-plus mr-1" />
{{ $t('secrets.addSecret') }}
</Button>
</div>
<div v-if="loading" class="flex items-center justify-center py-8">
<ProgressSpinner class="h-8 w-8" />
</div>
<div
v-else-if="secrets.length === 0"
class="py-4 text-center text-sm text-muted"
>
{{ $t('secrets.noSecrets') }}
</div>
<div v-else class="flex flex-col gap-3">
<SecretListItem
v-for="secret in secrets"
:key="secret.id"
:secret="secret"
:loading="operatingSecretId === secret.id"
:disabled="operatingSecretId !== null"
@edit="openEditDialog(secret)"
@delete="confirmDelete(secret)"
/>
</div>
<SecretFormDialog
v-model:visible="createDialogVisible"
mode="create"
:existing-providers="existingProviders"
@saved="fetchSecrets"
/>
<SecretFormDialog
v-model:visible="editDialogVisible"
mode="edit"
:secret="selectedSecret"
:existing-providers="existingProviders"
@saved="fetchSecrets"
/>
<ConfirmDialog group="secrets" />
</div>
</TabPanel>
</template>
<script setup lang="ts">
import ConfirmDialog from 'primevue/confirmdialog'
import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner'
import TabPanel from 'primevue/tabpanel'
import { useConfirm } from 'primevue/useconfirm'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useSecrets } from '../composables/useSecrets'
import type { SecretMetadata } from '../types'
import SecretFormDialog from './SecretFormDialog.vue'
import SecretListItem from './SecretListItem.vue'
const { t } = useI18n()
const confirm = useConfirm()
const {
loading,
secrets,
operatingSecretId,
existingProviders,
fetchSecrets,
deleteSecret
} = useSecrets()
const createDialogVisible = ref(false)
const editDialogVisible = ref(false)
const selectedSecret = ref<SecretMetadata | undefined>()
function openCreateDialog() {
createDialogVisible.value = true
}
function openEditDialog(secret: SecretMetadata) {
selectedSecret.value = secret
editDialogVisible.value = true
}
function confirmDelete(secret: SecretMetadata) {
confirm.require({
group: 'secrets',
header: t('secrets.deleteConfirmTitle'),
message: t('secrets.deleteConfirmMessage', { name: secret.name }),
acceptClass: 'p-button-danger',
accept: () => deleteSecret(secret)
})
}
fetchSecrets()
</script>

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

View File

@@ -0,0 +1,30 @@
import type { SecretProvider } from './types'
interface ProviderConfig {
value: SecretProvider
label: string
logo: string
}
export const SECRET_PROVIDERS: ProviderConfig[] = [
{
value: 'huggingface',
label: 'HuggingFace',
logo: '/assets/images/hf-logo.svg'
},
{ value: 'civitai', label: 'Civitai', logo: '/assets/images/civitai.svg' }
] as const
export function getProviderLabel(provider: SecretProvider | undefined): string {
if (!provider) return ''
const config = SECRET_PROVIDERS.find((p) => p.value === provider)
return config?.label ?? provider
}
export function getProviderLogo(
provider: SecretProvider | undefined
): string | undefined {
if (!provider) return undefined
const config = SECRET_PROVIDERS.find((p) => p.value === provider)
return config?.logo
}

View File

@@ -0,0 +1,32 @@
export type SecretProvider = 'huggingface' | 'civitai'
export interface SecretMetadata {
id: string
name: string
provider?: SecretProvider
last_used_at?: string
created_at: string
updated_at: string
}
export interface SecretCreateRequest {
name: string
secret_value: string
provider?: SecretProvider
}
export interface SecretUpdateRequest {
name?: string
secret_value?: string
}
export const SECRET_ERROR_CODES = [
'INVALID_REQUEST',
'INVALID_PROVIDER',
'DUPLICATE_NAME',
'DUPLICATE_PROVIDER',
'FORBIDDEN',
'NOT_FOUND'
] as const
export type SecretErrorCode = (typeof SECRET_ERROR_CODES)[number]

View File

@@ -129,6 +129,7 @@ const { defaultPanel } = defineProps<{
| 'credits'
| 'subscription'
| 'workspace'
| 'secrets'
}>()
const { flags } = useFeatureFlags()

View File

@@ -30,6 +30,7 @@ export function useSettingUI(
| 'credits'
| 'subscription'
| 'workspace'
| 'secrets'
) {
const { t } = useI18n()
const { isLoggedIn } = useCurrentUser()
@@ -169,6 +170,21 @@ export function useSettingUI(
() => teamWorkspacesEnabled.value && isLoggedIn.value
)
const secretsPanel: SettingPanelItem = {
node: {
key: 'secrets',
label: 'Secrets',
children: []
},
component: defineAsyncComponent(
() => import('@/platform/secrets/components/SecretsPanel.vue')
)
}
const shouldShowSecretsPanel = computed(
() => flags.userSecretsEnabled && isLoggedIn.value
)
const keybindingPanel: SettingPanelItem = {
node: {
key: 'keybinding',
@@ -213,7 +229,8 @@ export function useSettingUI(
...(isElectron() ? [serverConfigPanel] : []),
...(shouldShowPlanCreditsPanel.value && subscriptionPanel
? [subscriptionPanel]
: [])
: []),
...(shouldShowSecretsPanel.value ? [secretsPanel] : [])
].filter((panel) => panel !== null && panel.component)
)
@@ -249,7 +266,8 @@ export function useSettingUI(
...(isLoggedIn.value &&
!(isCloud && window.__CONFIG__?.subscription_required)
? [creditsPanel.node]
: [])
: []),
...(shouldShowSecretsPanel.value ? [secretsPanel.node] : [])
].map(translateCategory)
}),
// General settings - Profile + all core settings + special panels
@@ -290,6 +308,7 @@ export function useSettingUI(
subscriptionPanel
? [subscriptionPanel.node]
: []),
...(shouldShowSecretsPanel.value ? [secretsPanel.node] : []),
...(isLoggedIn.value &&
!(isCloud && window.__CONFIG__?.subscription_required)
? [creditsPanel.node]

View File

@@ -104,6 +104,7 @@ export const useDialogService = () => {
| 'credits'
| 'subscription'
| 'workspace'
| 'secrets'
) {
const props = panel ? { props: { defaultPanel: panel } } : undefined