From bac1b79a6bf5979b87efb46b3750e760ea342dd2 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 26 Jul 2026 14:39:24 +0300 Subject: [PATCH] feat(app): add storage integration settings - Add schema-driven S3 preferences and status-only credential controls to unified Settings - Keep storage preferences persistent while secrets remain in the selected credential backend - Include storage credentials when browser persistence changes and test session behavior Co-authored-by: Rob Coenen <753704+rcoenen@users.noreply.github.com> --- packages/vue/src/i18n/locales/de/dialogs.json | 7 + packages/vue/src/i18n/locales/es/dialogs.json | 7 + packages/vue/src/i18n/locales/fr/dialogs.json | 7 + packages/vue/src/i18n/locales/it/dialogs.json | 7 + packages/vue/src/i18n/locales/ja/dialogs.json | 7 + packages/vue/src/i18n/locales/pl/dialogs.json | 7 + packages/vue/src/i18n/locales/ru/dialogs.json | 7 + .../vue/src/i18n/locales/zh-cn/dialogs.json | 7 + packages/vue/src/i18n/messages/dialogs.ts | 7 + src/app/ai/chat/storage.ts | 16 +- src/app/integrations/storage/index.ts | 12 ++ src/app/integrations/storage/preferences.ts | 45 +++++ src/app/integrations/storage/runtime.ts | 43 +++++ src/app/settings/credentials/persistence.ts | 25 +++ src/app/settings/dialog.ts | 2 +- src/app/storage/local-store/types.ts | 2 +- src/components/settings/SettingsDialog.vue | 19 +- .../settings/storage/StorageSettingsPanel.vue | 168 ++++++++++++++++++ tests/e2e/settings/credentials.spec.ts | 31 ++++ .../app/integrations/storage/registry.test.ts | 4 + 20 files changed, 414 insertions(+), 16 deletions(-) create mode 100644 src/app/integrations/storage/preferences.ts create mode 100644 src/app/integrations/storage/runtime.ts create mode 100644 src/app/settings/credentials/persistence.ts create mode 100644 src/components/settings/storage/StorageSettingsPanel.vue diff --git a/packages/vue/src/i18n/locales/de/dialogs.json b/packages/vue/src/i18n/locales/de/dialogs.json index de34ab677..22e10b15e 100644 --- a/packages/vue/src/i18n/locales/de/dialogs.json +++ b/packages/vue/src/i18n/locales/de/dialogs.json @@ -69,6 +69,13 @@ "settingsDescription": "Integrationen und App-Einstellungen verwalten.", "settingsAIAndAgents": "KI und Agenten", "settingsMedia": "Medien", + "settingsStorage": "Cloud-Speicher", + "storageEndpoint": "Endpunkt", + "storageBucket": "Bucket", + "storageRegion": "Region", + "storageAccessKeyID": "Zugriffsschlüssel-ID", + "storageSecretAccessKey": "Geheimer Zugriffsschlüssel", + "save": "Speichern", "credentialStorage": "Zugangsdaten: {backend}", "credentialBackendNative": "System-Schlüsselbund", "credentialBackendBrowser": "verschlüsselter Browser-Speicher", diff --git a/packages/vue/src/i18n/locales/es/dialogs.json b/packages/vue/src/i18n/locales/es/dialogs.json index 195a0c533..aff7acd1e 100644 --- a/packages/vue/src/i18n/locales/es/dialogs.json +++ b/packages/vue/src/i18n/locales/es/dialogs.json @@ -69,6 +69,13 @@ "settingsDescription": "Gestiona integraciones y preferencias de la aplicación.", "settingsAIAndAgents": "IA y agentes", "settingsMedia": "Multimedia", + "settingsStorage": "Almacenamiento en la nube", + "storageEndpoint": "Punto de conexión", + "storageBucket": "Bucket", + "storageRegion": "Región", + "storageAccessKeyID": "ID de clave de acceso", + "storageSecretAccessKey": "Clave de acceso secreta", + "save": "Guardar", "credentialStorage": "Credenciales: {backend}", "credentialBackendNative": "almacén de credenciales del sistema", "credentialBackendBrowser": "almacenamiento cifrado del navegador", diff --git a/packages/vue/src/i18n/locales/fr/dialogs.json b/packages/vue/src/i18n/locales/fr/dialogs.json index 8bd24b7d9..c0801fc01 100644 --- a/packages/vue/src/i18n/locales/fr/dialogs.json +++ b/packages/vue/src/i18n/locales/fr/dialogs.json @@ -69,6 +69,13 @@ "settingsDescription": "Gérez les intégrations et les préférences de l’application.", "settingsAIAndAgents": "IA et agents", "settingsMedia": "Médias", + "settingsStorage": "Stockage cloud", + "storageEndpoint": "Point de terminaison", + "storageBucket": "Bucket", + "storageRegion": "Région", + "storageAccessKeyID": "ID de clé d’accès", + "storageSecretAccessKey": "Clé d’accès secrète", + "save": "Enregistrer", "credentialStorage": "Identifiants : {backend}", "credentialBackendNative": "trousseau système", "credentialBackendBrowser": "stockage chiffré du navigateur", diff --git a/packages/vue/src/i18n/locales/it/dialogs.json b/packages/vue/src/i18n/locales/it/dialogs.json index 35b31c2c4..4ac8f048f 100644 --- a/packages/vue/src/i18n/locales/it/dialogs.json +++ b/packages/vue/src/i18n/locales/it/dialogs.json @@ -69,6 +69,13 @@ "settingsDescription": "Gestisci integrazioni e preferenze dell’app.", "settingsAIAndAgents": "IA e agenti", "settingsMedia": "Media", + "settingsStorage": "Archiviazione cloud", + "storageEndpoint": "Endpoint", + "storageBucket": "Bucket", + "storageRegion": "Regione", + "storageAccessKeyID": "ID chiave di accesso", + "storageSecretAccessKey": "Chiave di accesso segreta", + "save": "Salva", "credentialStorage": "Credenziali: {backend}", "credentialBackendNative": "portachiavi di sistema", "credentialBackendBrowser": "archiviazione browser crittografata", diff --git a/packages/vue/src/i18n/locales/ja/dialogs.json b/packages/vue/src/i18n/locales/ja/dialogs.json index c17d8416e..e32126ebc 100644 --- a/packages/vue/src/i18n/locales/ja/dialogs.json +++ b/packages/vue/src/i18n/locales/ja/dialogs.json @@ -69,6 +69,13 @@ "settingsDescription": "連携サービスとアプリの設定を管理します。", "settingsAIAndAgents": "AIとエージェント", "settingsMedia": "メディア", + "settingsStorage": "クラウドストレージ", + "storageEndpoint": "エンドポイント", + "storageBucket": "バケット", + "storageRegion": "リージョン", + "storageAccessKeyID": "アクセスキー ID", + "storageSecretAccessKey": "シークレットアクセスキー", + "save": "保存", "credentialStorage": "認証情報: {backend}", "credentialBackendNative": "システムの認証情報ストア", "credentialBackendBrowser": "暗号化されたブラウザストレージ", diff --git a/packages/vue/src/i18n/locales/pl/dialogs.json b/packages/vue/src/i18n/locales/pl/dialogs.json index a0cd4d402..cf0605428 100644 --- a/packages/vue/src/i18n/locales/pl/dialogs.json +++ b/packages/vue/src/i18n/locales/pl/dialogs.json @@ -69,6 +69,13 @@ "settingsDescription": "Zarządzaj integracjami i preferencjami aplikacji.", "settingsAIAndAgents": "AI i agenci", "settingsMedia": "Media", + "settingsStorage": "Przechowywanie w chmurze", + "storageEndpoint": "Punkt końcowy", + "storageBucket": "Zasobnik", + "storageRegion": "Region", + "storageAccessKeyID": "Identyfikator klucza dostępu", + "storageSecretAccessKey": "Tajny klucz dostępu", + "save": "Zapisz", "credentialStorage": "Dane logowania: {backend}", "credentialBackendNative": "systemowy magazyn poświadczeń", "credentialBackendBrowser": "zaszyfrowana pamięć przeglądarki", diff --git a/packages/vue/src/i18n/locales/ru/dialogs.json b/packages/vue/src/i18n/locales/ru/dialogs.json index 0dca59b9b..66116c231 100644 --- a/packages/vue/src/i18n/locales/ru/dialogs.json +++ b/packages/vue/src/i18n/locales/ru/dialogs.json @@ -69,6 +69,13 @@ "settingsDescription": "Управление интеграциями и настройками приложения.", "settingsAIAndAgents": "ИИ и агенты", "settingsMedia": "Медиа", + "settingsStorage": "Облачное хранилище", + "storageEndpoint": "Конечная точка", + "storageBucket": "Бакет", + "storageRegion": "Регион", + "storageAccessKeyID": "Идентификатор ключа доступа", + "storageSecretAccessKey": "Секретный ключ доступа", + "save": "Сохранить", "credentialStorage": "Учётные данные: {backend}", "credentialBackendNative": "системное хранилище учётных данных", "credentialBackendBrowser": "зашифрованное хранилище браузера", diff --git a/packages/vue/src/i18n/locales/zh-cn/dialogs.json b/packages/vue/src/i18n/locales/zh-cn/dialogs.json index b1241a3ae..367e72614 100644 --- a/packages/vue/src/i18n/locales/zh-cn/dialogs.json +++ b/packages/vue/src/i18n/locales/zh-cn/dialogs.json @@ -69,6 +69,13 @@ "settingsDescription": "管理集成和应用偏好设置。", "settingsAIAndAgents": "AI 和代理", "settingsMedia": "媒体", + "settingsStorage": "云存储", + "storageEndpoint": "端点", + "storageBucket": "存储桶", + "storageRegion": "区域", + "storageAccessKeyID": "访问密钥 ID", + "storageSecretAccessKey": "机密访问密钥", + "save": "保存", "credentialStorage": "凭据:{backend}", "credentialBackendNative": "系统凭据存储", "credentialBackendBrowser": "加密浏览器存储", diff --git a/packages/vue/src/i18n/messages/dialogs.ts b/packages/vue/src/i18n/messages/dialogs.ts index d58b15b03..b9ede2628 100644 --- a/packages/vue/src/i18n/messages/dialogs.ts +++ b/packages/vue/src/i18n/messages/dialogs.ts @@ -118,6 +118,13 @@ export const dialogMessageDefaults = { settingsDescription: 'Manage integrations and app preferences.', settingsAIAndAgents: 'AI & agents', settingsMedia: 'Media', + settingsStorage: 'Cloud storage', + storageEndpoint: 'Endpoint', + storageBucket: 'Bucket', + storageRegion: 'Region', + storageAccessKeyID: 'Access key ID', + storageSecretAccessKey: 'Secret access key', + save: 'Save', credentialStorage: params('Credentials: {backend}'), credentialBackendNative: 'system credential store', credentialBackendBrowser: 'encrypted browser storage', diff --git a/src/app/ai/chat/storage.ts b/src/app/ai/chat/storage.ts index f562eae9f..c8cb0fac3 100644 --- a/src/app/ai/chat/storage.ts +++ b/src/app/ai/chat/storage.ts @@ -10,17 +10,14 @@ import { import type { AIProviderID } from '@open-pencil/core/constants' import { setPexelsApiKey, setUnsplashAccessKey } from '@open-pencil/core/tools' -import { - appCredentialServices, - browserCredentialsRemembered, - setBrowserCredentialPersistence -} from '@/app/settings/credentials/app' +import { appCredentialServices, browserCredentialsRemembered } from '@/app/settings/credentials/app' import { initializeCredentialMigration, PEXELS_CREDENTIAL, providerCredentialRef, UNSPLASH_CREDENTIAL } from '@/app/settings/credentials/migration' +import { setAppCredentialPersistence } from '@/app/settings/credentials/persistence' import type { CredentialRef, CredentialStatus } from '@/app/settings/credentials/types' const STORAGE_PREFIX = 'open-pencil:' @@ -128,14 +125,7 @@ export async function setUnsplashKey(key: string): Promise { export async function setRememberCredentials(remembered: boolean): Promise { await credentialsReady - const providerCredentials = AI_PROVIDERS.filter( - (provider) => !provider.id.startsWith('acp:') - ).map((provider) => providerCredentialRef(provider.id)) - await setBrowserCredentialPersistence(remembered, [ - ...providerCredentials, - PEXELS_CREDENTIAL, - UNSPLASH_CREDENTIAL - ]) + await setAppCredentialPersistence(remembered) await Promise.all([refreshProviderStatus(providerID.value), refreshMediaCredentials()]) credentialRevision.value++ } diff --git a/src/app/integrations/storage/index.ts b/src/app/integrations/storage/index.ts index 0e67057bd..10fc5fa07 100644 --- a/src/app/integrations/storage/index.ts +++ b/src/app/integrations/storage/index.ts @@ -1,8 +1,20 @@ +export { + activeStorageProviderID, + readStoragePreferences, + storagePreferencesComplete, + writeStoragePreference +} from './preferences' +export type { StoragePreferences } from './preferences' export { S3_STORAGE_PROVIDER, storageProviderRegistry } from './providers' export { defineStorageProvider, StorageProviderRegistry } from './registry' export { createS3StorageAdapter } from './s3/adapter' export type { S3StorageAdapter } from './s3/adapter' export type { S3CompatibleConfig, S3ConnectionResult } from './s3/types' +export { + createActiveStorageAdapter, + storageCredentialRefs, + storageCredentialStatuses +} from './runtime' export type { StorageAdapter, StorageAdapterContext, diff --git a/src/app/integrations/storage/preferences.ts b/src/app/integrations/storage/preferences.ts new file mode 100644 index 000000000..e6d120c4d --- /dev/null +++ b/src/app/integrations/storage/preferences.ts @@ -0,0 +1,45 @@ +import { useLocalStorage } from '@vueuse/core' + +import { storageProviderRegistry } from './providers' +import type { StorageFieldID, StorageProviderID } from './types' + +export type StoragePreferences = Record> + +export const activeStorageProviderID = useLocalStorage( + 'open-pencil:storage:provider', + 's3-compatible' +) + +const storedPreferences = useLocalStorage('open-pencil:storage:preferences', {}) + +export function readStoragePreferences( + providerID: StorageProviderID +): Readonly> { + return { ...storedPreferences.value[providerID] } +} + +export function writeStoragePreference( + providerID: StorageProviderID, + field: StorageFieldID, + value: string +): void { + const provider = storageProviderRegistry.get(providerID) + if (!provider.preferenceFields.some((definition) => definition.id === field)) { + throw new Error(`Unknown preference field for ${providerID}: ${field}`) + } + storedPreferences.value = { + ...storedPreferences.value, + [providerID]: { + ...storedPreferences.value[providerID], + [field]: value.trim() + } + } +} + +export function storagePreferencesComplete(providerID: StorageProviderID): boolean { + const provider = storageProviderRegistry.get(providerID) + const preferences = readStoragePreferences(providerID) + return provider.preferenceFields.every( + (field) => !field.required || Boolean(preferences[field.id]?.trim()) + ) +} diff --git a/src/app/integrations/storage/runtime.ts b/src/app/integrations/storage/runtime.ts new file mode 100644 index 000000000..0d24ba200 --- /dev/null +++ b/src/app/integrations/storage/runtime.ts @@ -0,0 +1,43 @@ +import { appCredentialServices } from '@/app/settings/credentials/app' +import { credentialRef } from '@/app/settings/credentials/reference' +import type { CredentialRef, CredentialStatus } from '@/app/settings/credentials/types' + +import { activeStorageProviderID, readStoragePreferences } from './preferences' +import { storageProviderRegistry } from './providers' +import type { StorageAdapter, StorageProviderID } from './types' + +export function storageCredentialRefs( + providerID: StorageProviderID, + profileID = 'default' +): CredentialRef[] { + return storageProviderRegistry + .get(providerID) + .credentialFields.map((field) => credentialRef(providerID, field.id, profileID)) +} + +export async function storageCredentialStatuses( + providerID: StorageProviderID, + profileID = 'default' +): Promise> { + const provider = storageProviderRegistry.get(providerID) + const entries = await Promise.all( + provider.credentialFields.map(async (field) => { + const status = await appCredentialServices.manager.status( + credentialRef(providerID, field.id, profileID) + ) + return [field.id, status] as const + }) + ) + return Object.fromEntries(entries) +} + +export function createActiveStorageAdapter( + providerID: StorageProviderID = activeStorageProviderID.value, + profileID = 'default' +): StorageAdapter { + return storageProviderRegistry.createAdapter(providerID, { + preferences: readStoragePreferences(providerID), + credentials: appCredentialServices.resolver, + profileId: profileID + }) +} diff --git a/src/app/settings/credentials/persistence.ts b/src/app/settings/credentials/persistence.ts new file mode 100644 index 000000000..63d2185ff --- /dev/null +++ b/src/app/settings/credentials/persistence.ts @@ -0,0 +1,25 @@ +import { AI_PROVIDERS } from '@open-pencil/core/constants' + +import { storageCredentialRefs, storageProviderRegistry } from '@/app/integrations/storage' +import { + PEXELS_CREDENTIAL, + UNSPLASH_CREDENTIAL, + providerCredentialRef +} from '@/app/settings/credentials/migration' + +import { setBrowserCredentialPersistence } from './app' +import type { CredentialRef } from './types' + +export function appCredentialRefs(): CredentialRef[] { + const aiCredentials = AI_PROVIDERS.filter((provider) => !provider.id.startsWith('acp:')).map( + (provider) => providerCredentialRef(provider.id) + ) + const storageCredentials = storageProviderRegistry + .list() + .flatMap((provider) => storageCredentialRefs(provider.id)) + return [...aiCredentials, PEXELS_CREDENTIAL, UNSPLASH_CREDENTIAL, ...storageCredentials] +} + +export function setAppCredentialPersistence(remembered: boolean): Promise { + return setBrowserCredentialPersistence(remembered, appCredentialRefs()) +} diff --git a/src/app/settings/dialog.ts b/src/app/settings/dialog.ts index e51e7960c..a36863d20 100644 --- a/src/app/settings/dialog.ts +++ b/src/app/settings/dialog.ts @@ -1,6 +1,6 @@ import { ref } from 'vue' -export type SettingsSection = 'ai' | 'media' +export type SettingsSection = 'ai' | 'media' | 'storage' export const settingsDialogOpen = ref(false) export const settingsDialogSection = ref('ai') diff --git a/src/app/storage/local-store/types.ts b/src/app/storage/local-store/types.ts index 55c52a800..aa1736df3 100644 --- a/src/app/storage/local-store/types.ts +++ b/src/app/storage/local-store/types.ts @@ -1,4 +1,4 @@ -import type { StorageProviderID } from '@/app/integrations/storage' +import type { StorageProviderID } from '@/app/integrations/storage/types' export type LocalSyncStatus = 'synced' | 'pending' | 'error' | 'conflict' diff --git a/src/components/settings/SettingsDialog.vue b/src/components/settings/SettingsDialog.vue index 06d310932..578512325 100644 --- a/src/components/settings/SettingsDialog.vue +++ b/src/components/settings/SettingsDialog.vue @@ -15,6 +15,7 @@ import StockPhotoKeysSection from '@/components/settings/provider/StockPhotoKeys import TestConnectionSection from '@/components/settings/provider/TestConnectionSection.vue' import { provideProviderSettings } from '@/components/settings/provider/context' import ProviderSelectField from '@/components/settings/provider-select/ProviderSelectField.vue' +import StorageSettingsPanel from '@/components/settings/storage/StorageSettingsPanel.vue' import AppSwitch from '@/components/ui/AppSwitch.vue' import { AppDialogFooter, AppDialogHeader, AppDialogRoot } from '@/components/ui/dialog' @@ -87,6 +88,16 @@ const navigationClass = {{ dialogs.settingsMedia }} +
@@ -104,10 +115,16 @@ const navigationClass = -
+

{{ dialogs.settingsMedia }}

+ +
diff --git a/src/components/settings/storage/StorageSettingsPanel.vue b/src/components/settings/storage/StorageSettingsPanel.vue new file mode 100644 index 000000000..f2190edf6 --- /dev/null +++ b/src/components/settings/storage/StorageSettingsPanel.vue @@ -0,0 +1,168 @@ + + + diff --git a/tests/e2e/settings/credentials.spec.ts b/tests/e2e/settings/credentials.spec.ts index 100ad2450..01065071b 100644 --- a/tests/e2e/settings/credentials.spec.ts +++ b/tests/e2e/settings/credentials.spec.ts @@ -2,6 +2,37 @@ import { expect, test } from '@playwright/test' import { CanvasHelper } from '#tests/helpers/canvas' +test('storage settings keep secrets behind the credential manager', async ({ page }) => { + await page.goto('/?test') + const canvas = new CanvasHelper(page) + await canvas.waitForInit() + + await page.getByTestId('app-settings-trigger').click() + await page.getByTestId('settings-section-storage').click() + await page.getByLabel('Endpoint').fill('https://s3.example.com') + await page.getByLabel('Bucket').fill('designs') + + const secretField = page.locator('[data-credential="secret-access-key"]') + await secretField.locator('input').fill('storage-secret') + await secretField.getByRole('button', { name: 'Save' }).click() + await expect(secretField.locator('input')).toHaveValue('') + await expect(secretField.locator('input')).toHaveAttribute('placeholder', /Key saved/) + + await page.getByTestId('app-settings-done').click() + await page.getByTestId('app-settings-trigger').click() + await page.getByTestId('settings-section-storage').click() + await expect(secretField.locator('input')).toHaveValue('') + await secretField.getByRole('button', { name: 'Clear' }).click() + await page.getByTestId('app-settings-done').click() + + await page.reload() + await canvas.waitForInit() + await page.getByTestId('app-settings-trigger').click() + await page.getByTestId('settings-section-storage').click() + await expect(page.getByLabel('Endpoint')).toHaveValue('https://s3.example.com') + await expect(secretField.locator('input')).not.toHaveAttribute('placeholder', /Key saved/) +}) + test('remembered browser credentials survive reload and clear centrally', async ({ page }) => { await page.goto('/?test') const canvas = new CanvasHelper(page) diff --git a/tests/engine/app/integrations/storage/registry.test.ts b/tests/engine/app/integrations/storage/registry.test.ts index f67c30745..bf076dad8 100644 --- a/tests/engine/app/integrations/storage/registry.test.ts +++ b/tests/engine/app/integrations/storage/registry.test.ts @@ -7,6 +7,7 @@ import { type StorageAdapter, type StorageProviderRuntime } from '@/app/integrations/storage' +import { appCredentialRefs } from '@/app/settings/credentials/persistence' import { credentialKey } from '@/app/settings/credentials/reference' import type { CredentialRef, CredentialResolver } from '@/app/settings/credentials/types' @@ -63,6 +64,9 @@ describe('storage provider registry', () => { 'access-key-id', 'secret-access-key' ]) + expect(appCredentialRefs().map(credentialKey)).toContain( + 'v1:s3-compatible:default:secret-access-key' + ) }) test('lists provider schemas without resolving credentials', () => {