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>
This commit is contained in:
Danila Poyarkov 2026-07-26 14:39:24 +03:00
parent 56a3b77ac5
commit bac1b79a6b
20 changed files with 414 additions and 16 deletions

View file

@ -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",

View file

@ -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",

View file

@ -69,6 +69,13 @@
"settingsDescription": "Gérez les intégrations et les préférences de lapplication.",
"settingsAIAndAgents": "IA et agents",
"settingsMedia": "Médias",
"settingsStorage": "Stockage cloud",
"storageEndpoint": "Point de terminaison",
"storageBucket": "Bucket",
"storageRegion": "Région",
"storageAccessKeyID": "ID de clé daccès",
"storageSecretAccessKey": "Clé daccès secrète",
"save": "Enregistrer",
"credentialStorage": "Identifiants : {backend}",
"credentialBackendNative": "trousseau système",
"credentialBackendBrowser": "stockage chiffré du navigateur",

View file

@ -69,6 +69,13 @@
"settingsDescription": "Gestisci integrazioni e preferenze dellapp.",
"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",

View file

@ -69,6 +69,13 @@
"settingsDescription": "連携サービスとアプリの設定を管理します。",
"settingsAIAndAgents": "AIとエージェント",
"settingsMedia": "メディア",
"settingsStorage": "クラウドストレージ",
"storageEndpoint": "エンドポイント",
"storageBucket": "バケット",
"storageRegion": "リージョン",
"storageAccessKeyID": "アクセスキー ID",
"storageSecretAccessKey": "シークレットアクセスキー",
"save": "保存",
"credentialStorage": "認証情報: {backend}",
"credentialBackendNative": "システムの認証情報ストア",
"credentialBackendBrowser": "暗号化されたブラウザストレージ",

View file

@ -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",

View file

@ -69,6 +69,13 @@
"settingsDescription": "Управление интеграциями и настройками приложения.",
"settingsAIAndAgents": "ИИ и агенты",
"settingsMedia": "Медиа",
"settingsStorage": "Облачное хранилище",
"storageEndpoint": "Конечная точка",
"storageBucket": "Бакет",
"storageRegion": "Регион",
"storageAccessKeyID": "Идентификатор ключа доступа",
"storageSecretAccessKey": "Секретный ключ доступа",
"save": "Сохранить",
"credentialStorage": "Учётные данные: {backend}",
"credentialBackendNative": "системное хранилище учётных данных",
"credentialBackendBrowser": "зашифрованное хранилище браузера",

View file

@ -69,6 +69,13 @@
"settingsDescription": "管理集成和应用偏好设置。",
"settingsAIAndAgents": "AI 和代理",
"settingsMedia": "媒体",
"settingsStorage": "云存储",
"storageEndpoint": "端点",
"storageBucket": "存储桶",
"storageRegion": "区域",
"storageAccessKeyID": "访问密钥 ID",
"storageSecretAccessKey": "机密访问密钥",
"save": "保存",
"credentialStorage": "凭据:{backend}",
"credentialBackendNative": "系统凭据存储",
"credentialBackendBrowser": "加密浏览器存储",

View file

@ -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',

View file

@ -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<void> {
export async function setRememberCredentials(remembered: boolean): Promise<void> {
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++
}

View file

@ -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,

View file

@ -0,0 +1,45 @@
import { useLocalStorage } from '@vueuse/core'
import { storageProviderRegistry } from './providers'
import type { StorageFieldID, StorageProviderID } from './types'
export type StoragePreferences = Record<StorageProviderID, Record<StorageFieldID, string>>
export const activeStorageProviderID = useLocalStorage<StorageProviderID>(
'open-pencil:storage:provider',
's3-compatible'
)
const storedPreferences = useLocalStorage<StoragePreferences>('open-pencil:storage:preferences', {})
export function readStoragePreferences(
providerID: StorageProviderID
): Readonly<Record<StorageFieldID, string>> {
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())
)
}

View file

@ -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<Record<string, CredentialStatus>> {
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
})
}

View file

@ -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<void> {
return setBrowserCredentialPersistence(remembered, appCredentialRefs())
}

View file

@ -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<SettingsSection>('ai')

View file

@ -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'

View file

@ -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 =
<icon-lucide-image class="size-3.5" />
{{ dialogs.settingsMedia }}
</button>
<button
type="button"
:class="navigationClass"
:data-state="settingsDialogSection === 'storage' ? 'active' : 'inactive'"
data-test-id="settings-section-storage"
@click="settingsDialogSection = 'storage'"
>
<icon-lucide-cloud class="size-3.5" />
{{ dialogs.settingsStorage }}
</button>
</nav>
<div class="min-h-0 flex-1 overflow-y-auto p-4">
@ -104,10 +115,16 @@ const navigationClass =
<TestConnectionSection />
</section>
<section v-else class="flex flex-col gap-2.5" data-test-id="settings-media-panel">
<section
v-else-if="settingsDialogSection === 'media'"
class="flex flex-col gap-2.5"
data-test-id="settings-media-panel"
>
<h3 class="text-xs font-semibold text-surface">{{ dialogs.settingsMedia }}</h3>
<StockPhotoKeysSection />
</section>
<StorageSettingsPanel v-else />
</div>
</div>

View file

@ -0,0 +1,168 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from '@open-pencil/vue'
import {
activeStorageProviderID,
createActiveStorageAdapter,
readStoragePreferences,
storageCredentialStatuses,
storageProviderRegistry,
writeStoragePreference
} from '@/app/integrations/storage'
import { appCredentialServices } from '@/app/settings/credentials/app'
import { credentialRef } from '@/app/settings/credentials/reference'
import type { CredentialStatus } from '@/app/settings/credentials/types'
import AppInput from '@/components/ui/AppInput.vue'
const { dialogs } = useI18n()
const provider = storageProviderRegistry.get(activeStorageProviderID.value)
const preferenceDrafts = ref<Record<string, string>>({ ...readStoragePreferences(provider.id) })
const credentialDrafts = ref<Record<string, string>>({})
const credentialStatuses = ref<Record<string, CredentialStatus>>({})
const busy = ref(false)
const result = ref<{ ok: boolean; message: string } | null>(null)
function preferenceLabel(field: string): string {
if (field === 'endpoint') return dialogs.value.storageEndpoint
if (field === 'bucket') return dialogs.value.storageBucket
if (field === 'region') return dialogs.value.storageRegion
return field
}
function credentialLabel(field: string): string {
if (field === 'access-key-id') return dialogs.value.storageAccessKeyID
if (field === 'secret-access-key') return dialogs.value.storageSecretAccessKey
return field
}
async function refreshStatuses(): Promise<void> {
credentialStatuses.value = await storageCredentialStatuses(provider.id)
}
function savePreferences(): void {
for (const field of provider.preferenceFields) {
writeStoragePreference(provider.id, field.id, preferenceDrafts.value[field.id] ?? '')
}
}
async function saveCredential(field: string): Promise<void> {
const value = credentialDrafts.value[field]?.trim()
if (!value) return
await appCredentialServices.manager.set(credentialRef(provider.id, field), value)
credentialDrafts.value[field] = ''
await refreshStatuses()
}
async function clearCredential(field: string): Promise<void> {
await appCredentialServices.manager.clear(credentialRef(provider.id, field))
credentialDrafts.value[field] = ''
await refreshStatuses()
}
async function testConnection(): Promise<void> {
busy.value = true
result.value = null
try {
savePreferences()
for (const field of provider.credentialFields) {
await saveCredential(field.id)
}
result.value = await createActiveStorageAdapter(provider.id).testConnection()
} catch (error) {
result.value = {
ok: false,
message: error instanceof Error ? error.message : String(error)
}
} finally {
busy.value = false
}
}
onMounted(() => void refreshStatuses())
</script>
<template>
<section class="flex flex-col gap-3" data-test-id="settings-storage-panel">
<div>
<h3 class="text-xs font-semibold text-surface">{{ dialogs.settingsStorage }}</h3>
<p class="mt-0.5 text-[10px] text-muted">{{ provider.description }}</p>
</div>
<label
v-for="field in provider.preferenceFields"
:key="field.id"
class="flex flex-col gap-1 text-[10px] text-muted"
>
{{ preferenceLabel(field.id) }}
<AppInput
v-model="preferenceDrafts[field.id]"
:placeholder="field.placeholder"
size="sm"
tone="panel"
@change="savePreferences"
/>
</label>
<div
v-for="field in provider.credentialFields"
:key="field.id"
class="flex flex-col gap-1"
:data-credential="field.id"
>
<label class="text-[10px] text-muted">
{{ credentialLabel(field.id) }}
</label>
<div class="flex gap-2">
<AppInput
v-model="credentialDrafts[field.id]"
type="password"
:placeholder="
credentialStatuses[field.id] === 'configured'
? dialogs.keySavedReplace
: field.placeholder
"
size="sm"
tone="panel"
class="min-w-0 flex-1"
@enter="saveCredential(field.id)"
/>
<button
v-if="credentialDrafts[field.id]?.trim()"
type="button"
class="rounded bg-hover px-2 text-[10px] text-surface hover:bg-active"
@click="saveCredential(field.id)"
>
{{ dialogs.save }}
</button>
<button
v-else-if="credentialStatuses[field.id] === 'configured'"
type="button"
class="rounded px-2 text-[10px] text-muted hover:bg-hover hover:text-surface"
@click="clearCredential(field.id)"
>
{{ dialogs.clear }}
</button>
</div>
</div>
<button
type="button"
class="mt-1 rounded bg-accent px-3 py-1.5 text-[11px] font-medium text-white hover:bg-accent/90 disabled:opacity-50"
:disabled="busy"
data-test-id="settings-storage-test"
@click="testConnection"
>
{{ dialogs.testConnection }}
</button>
<p
v-if="result"
class="rounded border border-border bg-panel px-2 py-1.5 text-[10px] text-muted data-[state=success]:text-success data-[state=error]:text-danger"
:data-state="result.ok ? 'success' : 'error'"
role="status"
>
{{ result.message }}
</p>
</section>
</template>

View file

@ -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)

View file

@ -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', () => {