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:
parent
56a3b77ac5
commit
bac1b79a6b
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -69,6 +69,13 @@
|
|||
"settingsDescription": "連携サービスとアプリの設定を管理します。",
|
||||
"settingsAIAndAgents": "AIとエージェント",
|
||||
"settingsMedia": "メディア",
|
||||
"settingsStorage": "クラウドストレージ",
|
||||
"storageEndpoint": "エンドポイント",
|
||||
"storageBucket": "バケット",
|
||||
"storageRegion": "リージョン",
|
||||
"storageAccessKeyID": "アクセスキー ID",
|
||||
"storageSecretAccessKey": "シークレットアクセスキー",
|
||||
"save": "保存",
|
||||
"credentialStorage": "認証情報: {backend}",
|
||||
"credentialBackendNative": "システムの認証情報ストア",
|
||||
"credentialBackendBrowser": "暗号化されたブラウザストレージ",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -69,6 +69,13 @@
|
|||
"settingsDescription": "Управление интеграциями и настройками приложения.",
|
||||
"settingsAIAndAgents": "ИИ и агенты",
|
||||
"settingsMedia": "Медиа",
|
||||
"settingsStorage": "Облачное хранилище",
|
||||
"storageEndpoint": "Конечная точка",
|
||||
"storageBucket": "Бакет",
|
||||
"storageRegion": "Регион",
|
||||
"storageAccessKeyID": "Идентификатор ключа доступа",
|
||||
"storageSecretAccessKey": "Секретный ключ доступа",
|
||||
"save": "Сохранить",
|
||||
"credentialStorage": "Учётные данные: {backend}",
|
||||
"credentialBackendNative": "системное хранилище учётных данных",
|
||||
"credentialBackendBrowser": "зашифрованное хранилище браузера",
|
||||
|
|
|
|||
|
|
@ -69,6 +69,13 @@
|
|||
"settingsDescription": "管理集成和应用偏好设置。",
|
||||
"settingsAIAndAgents": "AI 和代理",
|
||||
"settingsMedia": "媒体",
|
||||
"settingsStorage": "云存储",
|
||||
"storageEndpoint": "端点",
|
||||
"storageBucket": "存储桶",
|
||||
"storageRegion": "区域",
|
||||
"storageAccessKeyID": "访问密钥 ID",
|
||||
"storageSecretAccessKey": "机密访问密钥",
|
||||
"save": "保存",
|
||||
"credentialStorage": "凭据:{backend}",
|
||||
"credentialBackendNative": "系统凭据存储",
|
||||
"credentialBackendBrowser": "加密浏览器存储",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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++
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
45
src/app/integrations/storage/preferences.ts
Normal file
45
src/app/integrations/storage/preferences.ts
Normal 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())
|
||||
)
|
||||
}
|
||||
43
src/app/integrations/storage/runtime.ts
Normal file
43
src/app/integrations/storage/runtime.ts
Normal 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
|
||||
})
|
||||
}
|
||||
25
src/app/settings/credentials/persistence.ts
Normal file
25
src/app/settings/credentials/persistence.ts
Normal 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())
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
168
src/components/settings/storage/StorageSettingsPanel.vue
Normal file
168
src/components/settings/storage/StorageSettingsPanel.vue
Normal 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>
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue