diff --git a/src/composables/useFeatureFlags.ts b/src/composables/useFeatureFlags.ts index e0e69b4c9..63b2bbd7d 100644 --- a/src/composables/useFeatureFlags.ts +++ b/src/composables/useFeatureFlags.ts @@ -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) + ) } }) diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 811000ff7..1cacbbb29 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -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" + } } } diff --git a/src/platform/assets/components/UploadModelProgress.vue b/src/platform/assets/components/UploadModelProgress.vue index eb9ea7f3c..a1cb40b6f 100644 --- a/src/platform/assets/components/UploadModelProgress.vue +++ b/src/platform/assets/components/UploadModelProgress.vue @@ -83,7 +83,7 @@ diff --git a/src/platform/assets/composables/useUploadModelWizard.ts b/src/platform/assets/composables/useUploadModelWizard.ts index 9a945bad9..afac22142 100644 --- a/src/platform/assets/composables/useUploadModelWizard.ts +++ b/src/platform/assets/composables/useUploadModelWizard.ts @@ -69,9 +69,9 @@ export function useUploadModelWizard(modelTypes: Ref) { } ) - // 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(() => { diff --git a/src/platform/assets/services/assetService.ts b/src/platform/assets/services/assetService.ts index c3b150911..cbcfb3dae 100644 --- a/src/platform/assets/services/assetService.ts +++ b/src/platform/assets/services/assetService.ts @@ -36,6 +36,7 @@ interface AssetRequestOptions extends PaginationOptions { */ function getLocalizedErrorMessage(errorCode: string): string { const errorMessages: Record = { + // 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 ( diff --git a/src/platform/remoteConfig/types.ts b/src/platform/remoteConfig/types.ts index 7b8b1721c..6b54afc7a 100644 --- a/src/platform/remoteConfig/types.ts +++ b/src/platform/remoteConfig/types.ts @@ -43,4 +43,5 @@ export type RemoteConfig = { linear_toggle_enabled?: boolean async_model_upload_enabled?: boolean team_workspaces_enabled?: boolean + user_secrets_enabled?: boolean } diff --git a/src/platform/secrets/api/secretsApi.ts b/src/platform/secrets/api/secretsApi.ts new file mode 100644 index 000000000..84457d064 --- /dev/null +++ b/src/platform/secrets/api/secretsApi.ts @@ -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(response: Response): Promise { + 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 { + const response = await api.fetchApi('/secrets') + const data = await handleResponse(response) + return data.data +} + +export async function createSecret( + payload: SecretCreateRequest +): Promise { + const response = await api.fetchApi('/secrets', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }) + return handleResponse(response) +} + +export async function updateSecret( + id: string, + payload: SecretUpdateRequest +): Promise { + const response = await api.fetchApi(`/secrets/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }) + return handleResponse(response) +} + +export async function deleteSecret(id: string): Promise { + const response = await api.fetchApi(`/secrets/${id}`, { + method: 'DELETE' + }) + if (!response.ok) { + await handleResponse(response) + } +} diff --git a/src/platform/secrets/components/SecretFormDialog.vue b/src/platform/secrets/components/SecretFormDialog.vue new file mode 100644 index 000000000..5d99b9d82 --- /dev/null +++ b/src/platform/secrets/components/SecretFormDialog.vue @@ -0,0 +1,132 @@ + + + + + + {{ $t('secrets.provider') }} + + + + + + + + {{ option.label }} + + + + + {{ errors.provider }} + + + + + + {{ $t('secrets.name') }} + + + {{ errors.name }} + + + + + {{ $t('secrets.secretValue') }} + + + + {{ errors.secretValue }} + + + {{ + mode === 'edit' + ? $t('secrets.secretValueHintEdit') + : $t('secrets.secretValueHint') + }} + + + + + {{ apiError }} + + + + + {{ $t('g.cancel') }} + + + {{ $t('g.save') }} + + + + + + + diff --git a/src/platform/secrets/components/SecretListItem.test.ts b/src/platform/secrets/components/SecretListItem.test.ts new file mode 100644 index 000000000..6d7907ef7 --- /dev/null +++ b/src/platform/secrets/components/SecretListItem.test.ts @@ -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 { + 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: + '', + 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) + }) + }) +}) diff --git a/src/platform/secrets/components/SecretListItem.vue b/src/platform/secrets/components/SecretListItem.vue new file mode 100644 index 000000000..480d3d40a --- /dev/null +++ b/src/platform/secrets/components/SecretListItem.vue @@ -0,0 +1,91 @@ + + + + + {{ secret.name }} + + + {{ providerLabel }} + + + + {{ $t('secrets.createdAt', { date: createdDate }) }} + + {{ $t('secrets.lastUsed', { date: lastUsedDate }) }} + + + + + + + + + + + + + + + + + + diff --git a/src/platform/secrets/components/SecretsPanel.vue b/src/platform/secrets/components/SecretsPanel.vue new file mode 100644 index 000000000..85383a647 --- /dev/null +++ b/src/platform/secrets/components/SecretsPanel.vue @@ -0,0 +1,119 @@ + + + + + {{ $t('secrets.title') }} + {{ $t('secrets.description') }} + + {{ $t('secrets.descriptionUsage') }} + + + + + + + + {{ $t('secrets.modelProviders') }} + + + + {{ $t('secrets.addSecret') }} + + + + + + + + + {{ $t('secrets.noSecrets') }} + + + + + + + + + + + + + + + + diff --git a/src/platform/secrets/composables/useSecretForm.test.ts b/src/platform/secrets/composables/useSecretForm.test.ts new file mode 100644 index 000000000..3bb9deb76 --- /dev/null +++ b/src/platform/secrets/composables/useSecretForm.test.ts @@ -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 { + 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(['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('') + }) + }) +}) diff --git a/src/platform/secrets/composables/useSecretForm.ts b/src/platform/secrets/composables/useSecretForm.ts new file mode 100644 index 000000000..a74cfb12d --- /dev/null +++ b/src/platform/secrets/composables/useSecretForm.ts @@ -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 + existingProviders: MaybeRefOrGetter + 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(null) + const apiErrorMessage = ref(null) + + const form = reactive({ + name: '', + secretValue: '', + provider: null + }) + + const errors = reactive({ + name: '', + secretValue: '', + provider: '' + }) + + const providerOptions = computed(() => + 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 + } +} diff --git a/src/platform/secrets/composables/useSecrets.test.ts b/src/platform/secrets/composables/useSecrets.test.ts new file mode 100644 index 000000000..4cadeb5ae --- /dev/null +++ b/src/platform/secrets/composables/useSecrets.test.ts @@ -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 { + 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']) + }) + }) +}) diff --git a/src/platform/secrets/composables/useSecrets.ts b/src/platform/secrets/composables/useSecrets.ts new file mode 100644 index 000000000..fa7438eba --- /dev/null +++ b/src/platform/secrets/composables/useSecrets.ts @@ -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([]) + const operatingSecretId = ref(null) + + const existingProviders = computed(() => + 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 + } +} diff --git a/src/platform/secrets/providers.ts b/src/platform/secrets/providers.ts new file mode 100644 index 000000000..f1dff2bd9 --- /dev/null +++ b/src/platform/secrets/providers.ts @@ -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 +} diff --git a/src/platform/secrets/types.ts b/src/platform/secrets/types.ts new file mode 100644 index 000000000..a0fe2f0db --- /dev/null +++ b/src/platform/secrets/types.ts @@ -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] diff --git a/src/platform/settings/components/SettingDialogContent.vue b/src/platform/settings/components/SettingDialogContent.vue index 43377110b..09f2f6ee0 100644 --- a/src/platform/settings/components/SettingDialogContent.vue +++ b/src/platform/settings/components/SettingDialogContent.vue @@ -129,6 +129,7 @@ const { defaultPanel } = defineProps<{ | 'credits' | 'subscription' | 'workspace' + | 'secrets' }>() const { flags } = useFeatureFlags() diff --git a/src/platform/settings/composables/useSettingUI.ts b/src/platform/settings/composables/useSettingUI.ts index 3b4fc44a0..87924c972 100644 --- a/src/platform/settings/composables/useSettingUI.ts +++ b/src/platform/settings/composables/useSettingUI.ts @@ -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] diff --git a/src/services/dialogService.ts b/src/services/dialogService.ts index 3154c54d6..69171c895 100644 --- a/src/services/dialogService.ts +++ b/src/services/dialogService.ts @@ -104,6 +104,7 @@ export const useDialogService = () => { | 'credits' | 'subscription' | 'workspace' + | 'secrets' ) { const props = panel ? { props: { defaultPanel: panel } } : undefined
{{ $t('secrets.description') }}
+ {{ $t('secrets.descriptionUsage') }} +