feat(plugins): edit sensitive configuration per field (#582)

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-11-02 15:32:32 +08:00
committed by GitHub
co-authored by Zyy Codex
parent 2ac4265832
commit 35afe1123f
16 changed files with 290 additions and 180 deletions
@@ -165,8 +165,9 @@ pub struct ConfigurePluginInstance {
pub artifact_sha256: String,
pub enabled: bool,
pub configuration: serde_json::Value,
/// 缺省保留旧值;显式空对象清除全部 secret
pub secrets: Option<BTreeMap<String, SecretString>>,
/// 按字段更新,省略的字段保留,null 清除对应字段
#[serde(default)]
pub secrets: BTreeMap<String, Option<SecretString>>,
pub bindings: Vec<PluginCapabilityBinding>,
}
@@ -217,7 +217,7 @@ impl PluginsService {
artifact_sha256: metadata.sha256.clone(),
enabled: !configuration_required && compatible,
configuration: configuration.clone(),
secrets: None,
secrets: Default::default(),
bindings: bindings.clone(),
};
match self.configure_instance(None, input, context).await {
@@ -28,17 +28,16 @@ pub(super) fn same_creation_request(
existing: &PluginInstance,
input: &ConfigurePluginInstance,
) -> bool {
let same_secrets = input
let mut requested_secrets = input
.secrets
.as_ref()
.map_or(existing.secrets.is_empty(), |secrets| {
secrets.len() == existing.secrets.len()
&& secrets.iter().all(|(key, value)| {
existing
.secrets
.get(key)
.is_some_and(|current| current.expose_secret() == value.expose_secret())
})
.iter()
.filter_map(|(key, value)| value.as_ref().map(|value| (key, value)));
let same_secrets = requested_secrets.clone().count() == existing.secrets.len()
&& requested_secrets.all(|(key, value)| {
existing
.secrets
.get(key)
.is_some_and(|current| current.expose_secret() == value.expose_secret())
});
existing.name == input.name.trim()
&& existing.artifact_sha256 == input.artifact_sha256
@@ -158,7 +158,6 @@ impl PluginsService {
}
let saved = if let Some(previous) = &existing
&& previous.artifact_sha256 != input.artifact_sha256
&& input.secrets.is_none()
{
self.store
.load_version_configuration(&previous.id, &input.artifact_sha256)
@@ -167,6 +166,21 @@ impl PluginsService {
} else {
None
};
let mut secrets = saved.map(|saved| saved.secrets).unwrap_or_else(|| {
existing
.as_ref()
.map_or_else(Default::default, |instance| instance.secrets.clone())
});
for (name, value) in input.secrets {
match value {
Some(value) => {
secrets.insert(name, value);
}
None => {
secrets.remove(&name);
}
}
}
let instance = PluginInstance {
id: existing.as_ref().map_or_else(
|| {
@@ -182,14 +196,7 @@ impl PluginsService {
enabled: input.enabled,
trusted_process: true,
configuration: input.configuration,
secrets: input
.secrets
.or_else(|| saved.map(|saved| saved.secrets))
.unwrap_or_else(|| {
existing
.as_ref()
.map_or_else(Default::default, |instance| instance.secrets.clone())
}),
secrets,
bindings: input.bindings,
revision: candidate_revision,
};
@@ -69,7 +69,7 @@ impl PluginsService {
artifact_sha256: input.artifact_sha256,
enabled: existing.enabled,
configuration: saved.configuration,
secrets: Some(saved.secrets),
secrets: Default::default(),
bindings: saved.bindings,
};
// 沿用同一份快照的 CAS 和状态迁移流程,不能在检查目标后重新读取并覆盖并发配置
@@ -202,7 +202,7 @@ impl PluginsService {
artifact_sha256: input.artifact_sha256,
enabled: existing.enabled,
configuration: configuration.configuration,
secrets: Some(configuration.secrets),
secrets: Default::default(),
bindings: configuration.bindings,
};
// 同一次读取的 CAS 覆盖草稿计算、准备与提交,不能覆盖并发设置
@@ -0,0 +1,115 @@
//! 实例敏感配置的逐字段更新、版本基准与提交隔离测试
use super::*;
use secrecy::ExposeSecret;
fn secret_values(instance: &PluginInstance) -> serde_json::Value {
serde_json::Value::Object(
instance
.secrets
.iter()
.map(|(name, value)| (name.clone(), json!(value.expose_secret())))
.collect(),
)
}
#[tokio::test]
async fn secret_changes_apply_to_current_or_target_version_configuration() {
for (target, saved_version) in [
(OLD_ARTIFACT, false),
(NEW_ARTIFACT, false),
(NEW_ARTIFACT, true),
] {
let token = if saved_version {
"saved-token"
} else {
"current-token"
};
let untouched = if saved_version {
"saved-value"
} else {
"current-value"
};
for (changes, expected) in [
(json!({}), json!({"token":token,"untouched":untouched})),
(
json!({"adminKey":"new-key"}),
json!({"token":token,"untouched":untouched,"adminKey":"new-key"}),
),
(
json!({"token":"new-token"}),
json!({"token":"new-token","untouched":untouched}),
),
(json!({"token":null}), json!({"untouched":untouched})),
(
json!({"token":null,"adminKey":"new-key","absent":null}),
json!({"untouched":untouched,"adminKey":"new-key"}),
),
] {
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
{
let mut data = fixture.data.lock().unwrap();
data.snapshot.instances[0].secrets = BTreeMap::from([
("token".into(), "current-token".into()),
("untouched".into(), "current-value".into()),
]);
if saved_version {
data.history.get_mut(NEW_ARTIFACT).unwrap().secrets = BTreeMap::from([
("token".into(), "saved-token".into()),
("untouched".into(), "saved-value".into()),
]);
} else {
data.history.clear();
}
}
let before = fixture.snapshot();
let mut request = input();
request.artifact_sha256 = target.into();
request.expected_revision = Some(10);
request.secrets = serde_json::from_value(changes).unwrap();
let result = service(fixture.clone(), Arc::new(Published::default()))
.configure_instance(Some(&before.instances[0].id), request, &context())
.await
.unwrap();
assert_eq!(secret_values(&result.instance), expected);
assert_eq!(secret_values(&fixture.snapshot().instances[0]), expected);
}
}
}
#[tokio::test]
async fn failed_secret_changes_leave_saved_values_unchanged() {
for failure in ["stale", "preparation", "size"] {
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
fixture.data.lock().unwrap().snapshot.instances[0].secrets =
BTreeMap::from([("token".into(), "saved-token".into())]);
let before = fixture.snapshot();
let mut request = input();
request.artifact_sha256 = OLD_ARTIFACT.into();
request.expected_revision = Some(if failure == "stale" { 9 } else { 10 });
request.secrets = BTreeMap::from([
("token".into(), None),
(
"adminKey".into(),
Some(
if failure == "size" {
"x".repeat(8193)
} else {
"new-key".into()
}
.into(),
),
),
]);
fixture.data.lock().unwrap().fail_preparation = failure == "preparation";
let result = service(fixture.clone(), Arc::new(Published::default()))
.configure_instance(Some(&before.instances[0].id), request, &context())
.await;
assert!(result.is_err(), "{failure} must reject the update");
assert_eq!(
secret_values(&fixture.snapshot().instances[0]),
secret_values(&before.instances[0])
);
assert!(fixture.data.lock().unwrap().saves.is_empty());
}
}
@@ -49,6 +49,7 @@ use serde_json::json;
use super::TestPluginPorts;
mod configuration;
mod queries;
const OLD_ARTIFACT: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
@@ -759,7 +760,7 @@ fn input() -> ConfigurePluginInstance {
artifact_sha256: NEW_ARTIFACT.into(),
enabled: true,
configuration: json!({}),
secrets: None,
secrets: Default::default(),
bindings: vec![],
}
}
@@ -942,6 +943,8 @@ async fn configuration_retry_reuses_creation_id_and_rejects_changed_draft() {
// 此用例验证创建重试,使用不需要私有状态迁移的固定版本
value.artifact_sha256 = OLD_ARTIFACT.into();
value.enabled = false;
value.secrets =
serde_json::from_value(json!({"token": "saved-token", "absent": null})).unwrap();
value
};
let created = service
@@ -955,6 +958,12 @@ async fn configuration_retry_reuses_creation_id_and_rejects_changed_draft() {
assert_eq!(created.instance.id, creation_id);
assert_eq!(retried.instance.id, creation_id);
assert_eq!(fixture.snapshot().instances.len(), 2);
use secrecy::ExposeSecret as _;
assert_eq!(
retried.instance.secrets["token"].expose_secret(),
"saved-token"
);
assert!(!retried.instance.secrets.contains_key("absent"));
let before = fixture.snapshot().config_revision;
let mut changed = request();
changed.name = "changed draft".into();
@@ -965,6 +974,17 @@ async fn configuration_retry_reuses_creation_id_and_rejects_changed_draft() {
.unwrap();
assert_eq!(error.kind(), AdminErrorKind::Conflict);
assert_eq!(fixture.snapshot().config_revision, before);
let mut changed = request();
changed
.secrets
.insert("token".into(), Some("different-token".into()));
let error = service
.configure_instance(None, changed, &context())
.await
.err()
.unwrap();
assert_eq!(error.kind(), AdminErrorKind::Conflict);
assert_eq!(fixture.snapshot().config_revision, before);
}
#[tokio::test]
@@ -1435,23 +1455,6 @@ async fn version_switch_preparation_failure_preserves_current_and_saved_settings
);
}
#[tokio::test]
async fn version_edit_preserves_the_target_versions_secrets_when_omitted() {
use secrecy::ExposeSecret as _;
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
let before = fixture.snapshot();
let mut request = input();
request.secrets = None;
let result = service(fixture, Arc::new(Published::default()))
.configure_instance(Some(&before.instances[0].id), request, &context())
.await
.unwrap();
assert_eq!(
result.instance.secrets["token"].expose_secret(),
"old-version-secret"
);
}
#[tokio::test]
async fn rollback_requires_a_saved_configuration_instead_of_reusing_incompatible_current_settings()
{
+2 -1
View File
@@ -2929,7 +2929,8 @@ GitHub 的 `location` 使用 `kind: "github"`、`repository: "owner/repo"`、`ta
创建与更新的配置字段为 `name`、`artifactSha256`、`enabled`、`configuration` 和必填的 `bindings`,
`secrets` 可选。输入严格拒绝未知字段。`artifactSha256` 必须指向
已接受的制品。`configuration` 是匹配插件 schema 的 JSON 对象;敏感字段必须
放入 `secrets`,空对象清除全部值。省略时同版本编辑保留当前值;跨版本编辑优先保留目标版本快照的密钥,没有快照则保留当前值
放入 `secrets`,按字段更新:字符串设置对应值,`null` 清除对应字段,未出现的字段保留。省略 `secrets` 或传空对象均不修改密钥
同版本编辑以当前值为基础;跨版本编辑优先以目标版本快照的密钥为基础,没有快照则使用当前值。创建以空集合为基础,密钥值不会返回给管理端
创建可传 `creationId`(标准小写 UUID),同一草稿重试复用该 ID,已保存且内容不同则返回 409,不创建副本或覆盖旧配置。
相同内容的重试仍会重新准备并发布,不保证配置 revision 不变。更新可传 `expectedRevision`,与实例列表的 `revision`
+2 -1
View File
@@ -48,7 +48,8 @@ flowchart LR
在插件详情中直接启用或停用,设置会保留,无需重新填写。插件没有可设置的参数或认证映射时不显示设置入口
“设置”按当前包的 schema 显示参数,敏感字段单独保存且不回显。首次补齐必填项后“保存并启用”;其他设置修改保持
“设置”按当前包的 schema 显示参数,敏感字段单独保存且不回显。每项显示保存状态,留空保留现有值,填写新值替换该项;
“清除”只删除对应字段,保存前可撤销。首次补齐必填项后“保存并启用”;其他设置修改保持
原有启停状态。请求处理能力随包自动注册,无需在安装时逐项勾选;可在“高级设置”中调整各挂载阶段的启停、顺序和失败处理。
模型请求阶段支持 Key、分组、模型等作用范围;HTTP、WebSocket 消息和服务调用全局生效,具体匹配由插件处理。
客户端 Key、账号分组、Provider 和模型等范围同时满足才生效,同一范围内任一值匹配即可
+1 -1
View File
@@ -266,7 +266,7 @@ export interface ConfigurePluginInstanceRequest {
artifactSha256: string
enabled: boolean
configuration: Record<string, unknown>
secrets?: Record<string, string>
secrets?: Record<string, string | null>
bindings: PluginCapabilityBinding[]
}
@@ -128,8 +128,8 @@ defineExpose({ focusField })
<ZForm v-if="showJson">
<ZFormItem :label="t('common.form.inputPlaceholder')" :error="error">
<template #extra>
<ZIconButton v-if="fields !== null" :aria-label="t('common.form.useForm')" size="small" variant="solid" :disabled="disabled || Boolean(error) || hasExtra || incompatibleValue" @click="jsonMode = false">
<List class="size-4" />
<ZIconButton v-if="fields !== null" :aria-label="t('common.form.useForm')" class="size-4!" variant="link" :disabled="disabled || Boolean(error) || hasExtra || incompatibleValue" @click="jsonMode = false">
<List class="size-3.5" />
</ZIconButton>
</template>
<ZTextarea v-model="text" :rows="8" :disabled="disabled" spellcheck="false" autocomplete="off" data-schema-json-control />
@@ -156,8 +156,8 @@ defineExpose({ focusField })
</ZPopover>
</template>
<template v-if="index === 0" #extra>
<ZIconButton :aria-label="t('common.form.editJson')" size="small" variant="solid" :disabled="disabled" @click="jsonMode = true">
<Braces class="size-4" />
<ZIconButton :aria-label="t('common.form.editJson')" class="size-4!" variant="link" :disabled="disabled" @click="jsonMode = true">
<Braces class="size-3.5" />
</ZIconButton>
</template>
<ZSelect
+8 -12
View File
@@ -51,27 +51,20 @@
},
"configuration": {
"fillInRequiredConfiguration": "Please fill in the required configuration",
"incompatibleSecrets": "The target version does not support some saved secrets, choose to replace or clear them",
"requiredSecretsCannotClear": "Required secrets cannot be cleared: {labels}",
"requiredSecretsMissing": "No saved values for required secrets: {labels}",
"incompatibleSecrets": "Clear the secrets no longer used by this version",
"secretsRequired": "Enter the required secrets",
"checkBasicSettings": "Please check basic settings",
"noParameters": "No plugin parameters required",
"sensitiveConfiguration": "Sensitive configuration",
"secretsHelp": "Saved tokens, keys and other private values are never shown",
"settingsSaved": "Settings saved: {count}",
"emptySecretPlaceholder": "Leave blank to clear this value",
"emptySecretPlaceholder": "Saved, leave blank to keep",
"about": "About {name}",
"enterNewValue": "Enter new value",
"enterNewValue": "Not set, enter a new value",
"pluginParameters": "Parameters",
"advancedSettings": "Requests",
"clientAuthentication": "Client auth",
"applyAndSwitch": "Apply and switch",
"saveAndEnable": "Save and enable",
"saveSettings": "Save",
"keepExistingValues": "Keep existing",
"replaceAllValues": "Replace all",
"clearAllValues": "Clear all",
"checkPluginParameters": "Please check plugin parameters",
"setRequestScopeOrCheckApplyTo": "Please set the request scope, or check \"Apply to all requests\"",
"invalidIdentityMappings": "Check the client authentication identity mappings",
@@ -79,7 +72,6 @@
"pluginSettings": "Plugin settings",
"unchangedVersion": "The current version is unchanged, edit settings before applying",
"pluginSettingsPartition": "Plugin settings partition",
"secretsModeLabel": "Secret storage action",
"customScope": "Custom scope",
"applyGlobally": "Apply globally",
"requestHandling": "Request handling: {value}",
@@ -104,7 +96,11 @@
"deleteHistoricalConfiguration": "Delete historical configuration"
},
"stale": "Plugin settings changed, refresh and try again",
"incompatible": "Adjust incompatible settings and try again"
"incompatible": "Adjust incompatible settings and try again",
"secretPendingClear": "Will be cleared on save",
"clearSecret": "Clear {name}",
"undoClearSecret": "Undo clearing {name}",
"secretNotDeclared": "No longer used, clear this value"
},
"details": {
"version": "Version",
+8 -12
View File
@@ -51,27 +51,20 @@
},
"configuration": {
"fillInRequiredConfiguration": "请填写必填配置",
"incompatibleSecrets": "目标版本不支持部分已保存的敏感配置,请选择替换或清除",
"requiredSecretsCannotClear": "必填敏感配置不能清除:{labels}",
"requiredSecretsMissing": "没有可保留的必填敏感配置:{labels}",
"incompatibleSecrets": "请清除当前版本不再使用的敏感配置",
"secretsRequired": "请填写必填敏感配置",
"checkBasicSettings": "请检查基本设置",
"noParameters": "无需填写插件参数",
"sensitiveConfiguration": "敏感配置",
"secretsHelp": "用于令牌、密钥等机密参数,已保存的值不会回显",
"settingsSaved": "已保存 {count} 项",
"emptySecretPlaceholder": "留空将清除此项",
"emptySecretPlaceholder": "已保存,留空保留现有值",
"about": "{name}说明",
"enterNewValue": "输入新值",
"enterNewValue": "未设置,输入新值",
"pluginParameters": "插件参数",
"advancedSettings": "高级设置",
"clientAuthentication": "客户端认证",
"applyAndSwitch": "应用并切换",
"saveAndEnable": "保存并启用",
"saveSettings": "保存设置",
"keepExistingValues": "保留现有值",
"replaceAllValues": "替换全部",
"clearAllValues": "清除全部",
"checkPluginParameters": "请检查插件参数",
"setRequestScopeOrCheckApplyTo": "请设置请求范围,或勾选“应用于所有请求”",
"invalidIdentityMappings": "请检查客户端认证身份映射",
@@ -79,7 +72,6 @@
"pluginSettings": "插件设置",
"unchangedVersion": "当前版本保持不变,修改后再应用",
"pluginSettingsPartition": "插件设置分区",
"secretsModeLabel": "敏感配置保存方式",
"customScope": "自定义范围",
"applyGlobally": "全局生效",
"requestHandling": "请求处理:{value}",
@@ -104,7 +96,11 @@
"deleteHistoricalConfiguration": "删除历史配置"
},
"stale": "插件设置已变更,请刷新后重试",
"incompatible": "请调整不兼容的设置后重试"
"incompatible": "请调整不兼容的设置后重试",
"secretPendingClear": "保存后清除此项",
"clearSecret": "清除{name}",
"undoClearSecret": "撤销清除{name}",
"secretNotDeclared": "当前版本不再使用,请清除"
},
"details": {
"version": "版本",
+8 -12
View File
@@ -51,27 +51,20 @@
},
"configuration": {
"fillInRequiredConfiguration": "請填寫必填配置",
"incompatibleSecrets": "目標版本不支持部分已保存的敏感配置,請選擇替換或清除",
"requiredSecretsCannotClear": "必填敏感配置不能清除:{labels}",
"requiredSecretsMissing": "沒有可保留的必填敏感配置:{labels}",
"incompatibleSecrets": "請清除當前版本不再使用的敏感配置",
"secretsRequired": "請填寫必填敏感配置",
"checkBasicSettings": "請檢查基本設置",
"noParameters": "無需填寫插件參數",
"sensitiveConfiguration": "敏感配置",
"secretsHelp": "用於令牌、密鑰等機密參數,已保存的值不會回顯",
"settingsSaved": "已保存 {count} 項",
"emptySecretPlaceholder": "留空將清除此項",
"emptySecretPlaceholder": "已保存,留空保留現有值",
"about": "{name}說明",
"enterNewValue": "輸入新值",
"enterNewValue": "未設置,輸入新值",
"pluginParameters": "插件參數",
"advancedSettings": "高級設置",
"clientAuthentication": "客戶端認證",
"applyAndSwitch": "應用並切換",
"saveAndEnable": "保存並啓用",
"saveSettings": "保存設置",
"keepExistingValues": "保留現有值",
"replaceAllValues": "替換全部",
"clearAllValues": "清除全部",
"checkPluginParameters": "請檢查插件參數",
"setRequestScopeOrCheckApplyTo": "請設置請求範圍,或勾選“應用於所有請求”",
"invalidIdentityMappings": "請檢查客戶端認證身份映射",
@@ -79,7 +72,6 @@
"pluginSettings": "插件設置",
"unchangedVersion": "當前版本保持不變,修改後再應用",
"pluginSettingsPartition": "插件設置分區",
"secretsModeLabel": "敏感配置保存方式",
"customScope": "自定義範圍",
"applyGlobally": "全局生效",
"requestHandling": "請求處理:{value}",
@@ -104,7 +96,11 @@
"deleteHistoricalConfiguration": "刪除歷史配置"
},
"stale": "插件設置已變更,請刷新後重試",
"incompatible": "請調整不兼容的設置後重試"
"incompatible": "請調整不兼容的設置後重試",
"secretPendingClear": "保存後清除此項",
"clearSecret": "清除{name}",
"undoClearSecret": "撤銷清除{name}",
"secretNotDeclared": "當前版本不再使用,請清除"
},
"details": {
"version": "版本",
@@ -1,7 +1,8 @@
<script setup lang="ts">
import type { JsonSchema } from '../utils/model'
import { ZEmpty, ZFormItem, ZInput, ZSegmented, ZTag } from '@codex-proxy/ui'
import { ZEmpty, ZFormItem, ZIconButton, ZInput } from '@codex-proxy/ui'
import { RotateCcw, X } from '@lucide/vue'
import { computed, nextTick, shallowRef, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -28,8 +29,7 @@ const emit = defineEmits<{
const { t } = useI18n({ useScope: 'global' })
const configuration = defineModel<Record<string, unknown>>('configuration', { required: true })
const secrets = defineModel<Record<string, string>>('secrets', { required: true })
const secretMode = defineModel<'preserve' | 'replace' | 'clear'>('secretMode', { required: true })
const secrets = defineModel<Record<string, string | null>>('secrets', { required: true })
const configurationText = shallowRef('{}')
const schemaForm = useTemplateRef<{ focusField: (key?: string) => Promise<boolean> }>('schemaForm')
const secretFieldsRoot = useTemplateRef<HTMLElement>('secretFieldsRoot')
@@ -49,30 +49,19 @@ const configurationSchema = computed(() => {
const secretSchemas = computed(() => {
const properties = (props.schema as JsonSchema).properties ?? {}
const required = (props.schema as JsonSchema).required ?? []
return props.secretFields.map(name => ({
return [
...new Set([
...props.secretFields,
...props.existingSecretFields,
]),
].map(name => ({
name,
schema: properties[name] ?? { type: 'string' },
required: required.includes(name),
required: props.secretFields.includes(name) && required.includes(name),
declared: props.secretFields.includes(name),
saved: props.existingSecretFields.includes(name),
}))
})
const secretModes = computed(() => ([
{
label: t('plugins.configuration.keepExistingValues'),
value: 'preserve',
},
{
label: t('plugins.configuration.replaceAllValues'),
value: 'replace',
},
{
label: t('plugins.configuration.clearAllValues'),
value: 'clear',
},
]))
const existingSecretLabels = computed(() => props.existingSecretFields.map(name => ({
name,
label: (props.schema as JsonSchema).properties?.[name]?.title || name,
})))
const requiredConfigurationFields = computed(() => (configurationSchema.value as JsonSchema).required ?? [])
const missingConfigurationFields = computed(() => requiredConfigurationFields.value.filter(name =>
!Object.hasOwn(configuration.value, name),
@@ -84,23 +73,46 @@ const configurationFieldErrors = computed(() => Object.fromEntries(
]),
))
const requiredSecretFields = computed(() => secretSchemas.value.filter(field => field.required))
const missingSecretFields = computed(() => requiredSecretFields.value.filter((field) => {
if (secretMode.value === 'clear')
return true
if (secretMode.value === 'preserve')
return !props.existingSecretFields.includes(field.name)
return !valuePresent(secrets.value[field.name])
}))
const secretModeError = computed(() => {
if (secretMode.value === 'preserve' && props.existingSecretFields.some(name => !props.secretFields.includes(name)))
return t('plugins.configuration.incompatibleSecrets')
if (!missingSecretFields.value.length || secretMode.value === 'replace')
return ''
const labels = missingSecretFields.value.map(field => field.schema.title || field.name).join('、')
return secretMode.value === 'clear'
? t('plugins.configuration.requiredSecretsCannotClear', { labels })
: t('plugins.configuration.requiredSecretsMissing', { labels })
})
const missingSecretFields = computed(() => requiredSecretFields.value.filter(field => !secretPresent(field.name)))
const incompatibleSecretFields = computed(() => secretSchemas.value.filter(field => !field.declared && secretPresent(field.name)))
const secretError = computed(() => incompatibleSecretFields.value.length
? t('plugins.configuration.incompatibleSecrets')
: '')
function secretChange(name: string) {
return Object.hasOwn(secrets.value, name)
? secrets.value[name]
: undefined
}
function secretPresent(name: string) {
const change = secretChange(name)
return change === undefined
? props.existingSecretFields.includes(name)
: valuePresent(change)
}
function secretPlaceholder(name: string) {
if (secretChange(name) === null)
return t('plugins.configuration.secretPendingClear')
if (!props.secretFields.includes(name))
return t('plugins.configuration.secretNotDeclared')
return props.existingSecretFields.includes(name)
? t('plugins.configuration.emptySecretPlaceholder')
: t('plugins.configuration.enterNewValue')
}
function toggleSecretClear(name: string) {
if (secretChange(name) === null) {
const next = { ...secrets.value }
delete next[name]
secrets.value = next
}
else {
secrets.value = { ...secrets.value, [name]: null }
}
}
const hasConfiguration = computed(() => {
const properties = (configurationSchema.value as JsonSchema).properties
return Boolean(properties && Object.keys(properties).length > 0)
@@ -126,10 +138,10 @@ function valuePresent(value: unknown) {
}
const valid = computed(() => !configurationError.value
&& !secretModeError.value
&& !secretError.value
&& !missingConfigurationFields.value.length
&& !missingSecretFields.value.length)
const validationMessage = computed(() => secretModeError.value
const validationMessage = computed(() => secretError.value
|| configurationError.value
|| (missingSecretFields.value.length
? t('plugins.configuration.secretsRequired')
@@ -153,8 +165,6 @@ watch(parsedConfiguration, ({ value }) => {
})
async function focusInvalid() {
if (secretModeError.value)
return 'secretMode' as const
if (configurationError.value) {
await schemaForm.value?.focusField()
return 'configuration' as const
@@ -163,18 +173,15 @@ async function focusInvalid() {
await schemaForm.value?.focusField(missingConfigurationFields.value[0])
return 'configuration' as const
}
if (!missingSecretFields.value.length)
const missingName = missingSecretFields.value[0]?.name ?? incompatibleSecretFields.value[0]?.name
if (!missingName)
return null
if (secretMode.value !== 'replace')
return 'secretMode' as const
await nextTick()
const missingName = missingSecretFields.value[0]?.name
const container = [
...(secretFieldsRoot.value?.querySelectorAll<HTMLElement>('[data-secret-field]') ?? []),
]
.find(element => element.dataset.secretField === missingName)
container?.querySelector<HTMLElement>('input:not([type="hidden"]), textarea, button, [tabindex]:not([tabindex="-1"])')?.focus()
container?.querySelector<HTMLInputElement>('input')?.focus()
return 'secret' as const
}
@@ -200,35 +207,14 @@ defineExpose({ focusInvalid, validationMessage })
:field-errors="configurationFieldErrors"
/>
<section v-if="hasSecrets" class="grid gap-3 rounded-z bg-z-fill-alter p-4" :aria-label="t('plugins.configuration.sensitiveConfiguration')">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<strong class="text-z leading-none text-z-text">{{ t('plugins.configuration.sensitiveConfiguration') }}</strong>
<ZTag v-if="existingSecretFields.length > 0" type="success" size="small">
{{ t('plugins.configuration.settingsSaved', { count: existingSecretFields.length }) }}
</ZTag>
</div>
<p class="m-0 text-z-xs leading-relaxed text-z-text-secondary">
{{ t('plugins.configuration.secretsHelp') }}
<section v-if="hasSecrets" class="grid gap-4 rounded-z bg-z-fill-alter p-4" :aria-label="t('plugins.configuration.sensitiveConfiguration')">
<h3 class="m-0 text-z-lg leading-none font-heavy text-z-text">
{{ t('plugins.configuration.sensitiveConfiguration') }}
</h3>
<p v-if="secretError" role="alert" class="m-0 text-z-xs leading-relaxed text-z-warning-text">
{{ secretError }}
</p>
<ZSegmented
v-if="existingSecretFields.length"
v-model="secretMode"
:options="secretModes"
:aria-label="t('plugins.configuration.secretsModeLabel')"
:disabled="disabled"
class="w-fit max-w-full"
/>
<p v-if="secretModeError" role="alert" class="m-0 text-z-xs leading-relaxed text-z-warning-text">
{{ secretModeError }}
</p>
<ul v-if="secretMode === 'preserve'" class="m-0 flex list-none flex-wrap gap-2 p-0">
<li v-for="field in existingSecretLabels" :key="field.name" class="min-w-0 wrap-anywhere">
<ZTag type="primary">
{{ field.label }}
</ZTag>
</li>
</ul>
<div v-if="secretMode === 'replace'" ref="secretFieldsRoot" class="grid items-end gap-4 sm:grid-cols-2">
<div ref="secretFieldsRoot" class="grid items-start gap-4 sm:grid-cols-2">
<ZFormItem
v-for="field in secretSchemas"
:key="field.name"
@@ -242,15 +228,33 @@ defineExpose({ focusInvalid, validationMessage })
</PluginHelpPopover>
</template>
<ZInput
:model-value="secrets[field.name] ?? ''"
:model-value="secretChange(field.name) ?? ''"
type="password"
autocomplete="new-password"
clearable
:disabled="disabled"
:readonly="!field.declared"
:aria-label="field.schema.title || field.name"
:aria-invalid="field.required && !valuePresent(secrets[field.name])"
:placeholder="existingSecretFields.includes(field.name) ? t('plugins.configuration.emptySecretPlaceholder') : t('plugins.configuration.enterNewValue')"
:aria-invalid="field.required && !secretPresent(field.name)"
:placeholder="secretPlaceholder(field.name)"
@update:model-value="setSecret(field.name, $event)"
/>
>
<template v-if="field.saved && !secretChange(field.name)" #suffix>
<ZIconButton
variant="link"
class="size-4!"
:disabled="disabled"
:aria-label="secretChange(field.name) === null ? t('plugins.configuration.undoClearSecret', { name: field.schema.title || field.name }) : t('plugins.configuration.clearSecret', { name: field.schema.title || field.name })"
@click="toggleSecretClear(field.name)"
>
<RotateCcw v-if="secretChange(field.name) === null" class="size-3.5" />
<X v-else class="size-3.5" />
</ZIconButton>
</template>
</ZInput>
<p v-if="field.required && !secretPresent(field.name)" role="alert" class="mt-1 text-z-xs text-z-warning-text">
{{ t('plugins.configuration.secretsRequired') }}
</p>
</ZFormItem>
</div>
</section>
@@ -2,7 +2,7 @@
import type { ConfigurePluginInstanceRequest, PluginArtifact, PluginInstance, PluginVersionPlan } from '@/api'
import { ZButton, ZDialog, ZSegmented } from '@codex-proxy/ui'
import { Blocks, Save, Settings2, ShieldCheck } from '@lucide/vue'
import { Blocks, Settings2, ShieldCheck } from '@lucide/vue'
import { cloneDeep } from 'es-toolkit'
import { computed, nextTick, ref, shallowRef, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -29,8 +29,7 @@ const section = shallowRef('general')
const configurationValid = shallowRef(true)
const authenticationValid = shallowRef(true)
const bindingsValid = shallowRef(true)
const secretMode = shallowRef<'preserve' | 'replace' | 'clear'>('preserve')
const secretValues = ref<Record<string, string>>({})
const secretValues = ref<Record<string, string | null>>({})
const configuration = ref<Record<string, unknown>>({})
const bindings = ref<ConfigurePluginInstanceRequest['bindings']>([])
const configurationFields = useTemplateRef<{ focusInvalid: () => Promise<unknown>, validationMessage: string }>('configurationFields')
@@ -117,11 +116,8 @@ async function submit() {
configuration: cloneDeep(configuration.value),
bindings: cloneDeep(bindings.value),
}
if (secretMode.value !== 'preserve') {
input.secrets = secretMode.value === 'clear'
? {}
: cloneDeep(secretValues.value)
}
if (Object.keys(secretValues.value).length)
input.secrets = cloneDeep(secretValues.value)
emit('save', input)
}
@@ -131,7 +127,6 @@ watch(open, async (value) => {
secretValues.value = {}
configuration.value = cloneDeep(props.draft?.configuration ?? props.instance?.configuration ?? {})
bindings.value = cloneDeep(props.draft?.bindings ?? props.instance?.bindings ?? [])
secretMode.value = existingSecretFields.value.length ? 'preserve' : 'replace'
configurationValid.value = true
authenticationValid.value = true
bindingsValid.value = true
@@ -158,7 +153,6 @@ function clearSecrets() {
ref="configurationFields"
v-model:configuration="configuration"
v-model:secrets="secretValues"
v-model:secret-mode="secretMode"
:schema="artifact.metadata.configurationSchema"
:secret-fields="artifact.metadata.secretFields"
:existing-secret-fields="existingSecretFields"
@@ -192,9 +186,6 @@ function clearSecrets() {
{{ t('common.actions.cancel') }}
</ZButton>
<ZButton type="primary" :loading="saving" :disabled="!instance || !artifact" @click="submit">
<template #icon>
<Save class="size-4" />
</template>
{{ primaryLabel }}
</ZButton>
</template>