mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-05 07:30:11 +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
@@ -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)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -43,4 +43,5 @@ export type RemoteConfig = {
|
||||
linear_toggle_enabled?: boolean
|
||||
async_model_upload_enabled?: boolean
|
||||
team_workspaces_enabled?: boolean
|
||||
user_secrets_enabled?: boolean
|
||||
}
|
||||
|
||||
89
src/platform/secrets/api/secretsApi.ts
Normal file
89
src/platform/secrets/api/secretsApi.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
132
src/platform/secrets/components/SecretFormDialog.vue
Normal file
132
src/platform/secrets/components/SecretFormDialog.vue
Normal 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>
|
||||
178
src/platform/secrets/components/SecretListItem.test.ts
Normal file
178
src/platform/secrets/components/SecretListItem.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
91
src/platform/secrets/components/SecretListItem.vue
Normal file
91
src/platform/secrets/components/SecretListItem.vue
Normal 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>
|
||||
119
src/platform/secrets/components/SecretsPanel.vue
Normal file
119
src/platform/secrets/components/SecretsPanel.vue
Normal 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>
|
||||
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
|
||||
}
|
||||
}
|
||||
30
src/platform/secrets/providers.ts
Normal file
30
src/platform/secrets/providers.ts
Normal 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
|
||||
}
|
||||
32
src/platform/secrets/types.ts
Normal file
32
src/platform/secrets/types.ts
Normal 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]
|
||||
@@ -129,6 +129,7 @@ const { defaultPanel } = defineProps<{
|
||||
| 'credits'
|
||||
| 'subscription'
|
||||
| 'workspace'
|
||||
| 'secrets'
|
||||
}>()
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -104,6 +104,7 @@ export const useDialogService = () => {
|
||||
| 'credits'
|
||||
| 'subscription'
|
||||
| 'workspace'
|
||||
| 'secrets'
|
||||
) {
|
||||
const props = panel ? { props: { defaultPanel: panel } } : undefined
|
||||
|
||||
|
||||
Reference in New Issue
Block a user