mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 00:06:08 +00:00
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:
@@ -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
@@ -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
@@ -48,7 +48,8 @@ flowchart LR
|
||||
|
||||
在插件详情中直接启用或停用,设置会保留,无需重新填写。插件没有可设置的参数或认证映射时不显示设置入口
|
||||
|
||||
“设置”按当前包的 schema 显示参数,敏感字段单独保存且不回显。首次补齐必填项后“保存并启用”;其他设置修改保持
|
||||
“设置”按当前包的 schema 显示参数,敏感字段单独保存且不回显。每项显示保存状态,留空保留现有值,填写新值替换该项;
|
||||
“清除”只删除对应字段,保存前可撤销。首次补齐必填项后“保存并启用”;其他设置修改保持
|
||||
原有启停状态。请求处理能力随包自动注册,无需在安装时逐项勾选;可在“高级设置”中调整各挂载阶段的启停、顺序和失败处理。
|
||||
模型请求阶段支持 Key、分组、模型等作用范围;HTTP、WebSocket 消息和服务调用全局生效,具体匹配由插件处理。
|
||||
客户端 Key、账号分组、Provider 和模型等范围同时满足才生效,同一范围内任一值匹配即可
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "版本",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user