From 3ee212daa8901570012587ff63446f0943e4a225 Mon Sep 17 00:00:00 2001 From: GuoHao <897690986@qq.com> Date: Mon, 20 Jul 2026 04:45:42 +0800 Subject: [PATCH 1/2] feat: add sensitive config and JSONC support - Add sensitive configuration: new Sensitive field on Config/PublishDetail entities, Config_ViewSensitive permission for controlling access to sensitive values, and mask logic (******) in admin API when user lacks the permission. - Add JSONC (JSON with Comments) support: DictionaryConvertToJsonC serializer outputs descriptions as inline // comments, JsonCConfigurationFileParser extracts comments back to descriptions using Newtonsoft.Json JsonTextReader. - Fix SaveFromDictAsync to set Description for newly created configs from JSONC. - EnvSync now propagates Sensitive and Description changes across environments. - Frontend: sensitive switch in add/edit forms, lock icon column, jsonc language in Monaco editor, i18n for zh-CN and en-US. - Unit tests for JSONC serializer, parser, and round-trip conversion. Co-Authored-By: Claude --- .../Controllers/ConfigController.cs | 62 ++++- .../Controllers/api/Models/ApiConfigVM.cs | 8 +- .../Models/ConfigVM.cs | 2 + .../Models/Mapping/ModelMappingExtension.cs | 4 +- .../DictionaryConvertToJsonC.cs | 219 ++++++++++++++++++ .../JsonCConfigurationFileParser.cs | 107 +++++++++ src/AgileConfig.Server.Data.Entity/Config.cs | 3 + .../PublishDetail.cs | 3 + .../IConfigService.cs | 11 + .../IPermissionService.cs | 3 + .../ConfigService.cs | 48 +++- .../react-ui-antd/src/locales/en-US/pages.ts | 4 + .../react-ui-antd/src/locales/zh-CN/pages.ts | 4 + .../react-ui-antd/src/models/functionKeys.ts | 1 + .../src/pages/Configs/comps/JsonEditor.tsx | 2 +- .../src/pages/Configs/comps/updateForm.tsx | 10 +- .../react-ui-antd/src/pages/Configs/data.d.ts | 1 + .../react-ui-antd/src/pages/Configs/index.tsx | 17 +- .../DictionaryConvertToJsonCTests.cs | 133 +++++++++++ .../JsonCConfigurationFileParserTests.cs | 184 +++++++++++++++ 20 files changed, 808 insertions(+), 18 deletions(-) create mode 100644 src/AgileConfig.Server.Common/DictionaryConvertToJsonC.cs create mode 100644 src/AgileConfig.Server.Common/JsonCConfigurationFileParser.cs create mode 100644 test/AgileConfig.Server.CommonTests/DictionaryConvertToJsonCTests.cs create mode 100644 test/AgileConfig.Server.CommonTests/JsonCConfigurationFileParserTests.cs diff --git a/src/AgileConfig.Server.Apisite/Controllers/ConfigController.cs b/src/AgileConfig.Server.Apisite/Controllers/ConfigController.cs index fabddd7f..1177a7a8 100644 --- a/src/AgileConfig.Server.Apisite/Controllers/ConfigController.cs +++ b/src/AgileConfig.Server.Apisite/Controllers/ConfigController.cs @@ -25,18 +25,21 @@ public class ConfigController : Controller private readonly IConfigService _configService; private readonly ITinyEventBus _tinyEventBus; private readonly IUserService _userService; + private readonly IPermissionService _permissionService; public ConfigController( IConfigService configService, IAppService appService, IUserService userService, - ITinyEventBus tinyEventBus + ITinyEventBus tinyEventBus, + IPermissionService permissionService ) { _configService = configService; _appService = appService; _userService = userService; _tinyEventBus = tinyEventBus; + _permissionService = permissionService; } [TypeFilter(typeof(PermissionCheckAttribute), Arguments = new object[] { Functions.Config_Add })] @@ -74,6 +77,7 @@ public async Task Add([FromBody] ConfigVM model, EnvString env) config.OnlineStatus = OnlineStatus.WaitPublish; config.EditStatus = EditStatus.Add; config.Env = env.Value; + config.Sensitive = model.Sensitive; var result = await _configService.AddAsync(config, env.Value); @@ -126,6 +130,7 @@ public async Task AddRange([FromBody] List model, EnvSt config.OnlineStatus = OnlineStatus.WaitPublish; config.EditStatus = EditStatus.Add; config.Env = env.Value; + config.Sensitive = item.Sensitive; addConfigs.Add(config); } @@ -172,7 +177,8 @@ public async Task Edit([FromBody] ConfigVM model, [FromQuery] Env { Key = config.Key, Group = config.Group, - Value = config.Value + Value = config.Value, + Sensitive = config.Sensitive }; if (config.Group != model.Group || config.Key != model.Key) { @@ -192,6 +198,7 @@ public async Task Edit([FromBody] ConfigVM model, [FromQuery] Env config.Group = model.Group; config.UpdateTime = DateTime.Now; config.Env = env.Value; + config.Sensitive = model.Sensitive; if (!IsOnlyUpdateDescription(config, oldConfig)) { @@ -229,6 +236,24 @@ private bool IsOnlyUpdateDescription(Config newConfig, Config oldConfig) newConfig.Value == oldConfig.Value; } + /// + /// Mask sensitive configuration values with "******" when the current user lacks + /// the Config_ViewSensitive permission. + /// + private async Task MaskSensitiveConfigs(List configs) + { + var userId = await this.GetCurrentUserId(_userService); + if (string.IsNullOrEmpty(userId)) return; + + var userPermissions = await _permissionService.GetUserPermission(userId); + if (userPermissions.Contains(Functions.Config_ViewSensitive)) return; + + foreach (var config in configs) + { + if (config.Sensitive) config.Value = "******"; + } + } + [HttpGet] public async Task All(string env) { @@ -236,6 +261,8 @@ public async Task All(string env) var configs = await _configService.GetAllConfigsAsync(env); + await MaskSensitiveConfigs(configs); + return Json(new { success = true, @@ -286,6 +313,8 @@ public async Task Search(string appId, string group, string key, var page = configs.Skip((current - 1) * pageSize).Take(pageSize).ToList(); var total = configs.Count(); + await MaskSensitiveConfigs(page); + return Json(new { current, @@ -305,6 +334,9 @@ public async Task Get(string id, EnvString env) var config = await _configService.GetAsync(id, env.Value); + if (config != null) + await MaskSensitiveConfigs(new List { config }); + return Json(new { success = config != null, @@ -486,7 +518,10 @@ public IActionResult PreViewJsonFile() var jsonFile = files.First(); using (var stream = jsonFile.OpenReadStream()) { - var dict = JsonConfigurationFileParser.Parse(stream); + // Try parsing as JSONC first; fall back to standard JSON if no comments found + var parseResult = JsonCConfigurationFileParser.Parse(stream); + var dict = parseResult.values; + var descriptions = parseResult.descriptions; var addConfigs = new List(); foreach (var key in dict.Keys) @@ -503,7 +538,7 @@ public IActionResult PreViewJsonFile() var config = new Config(); config.Key = newKey; - config.Description = ""; + config.Description = descriptions.TryGetValue(key, out var desc) ? desc : ""; config.Value = dict[key]; config.Group = group; config.Id = Guid.NewGuid().ToString(); @@ -531,6 +566,8 @@ public async Task ExportJson(string appId, EnvString env) var configs = await _configService.GetByAppIdAsync(appId, env.Value); + await MaskSensitiveConfigs(configs); + var dict = new Dictionary(); configs.ForEach(x => { @@ -685,6 +722,9 @@ public async Task GetKvList(string appId, EnvString env) var configs = await _configService.GetByAppIdAsync(appId, env.Value); // When displaying text format, exclude deleted configurations. configs = configs.Where(x => x.EditStatus != EditStatus.Deleted).ToList(); + + await MaskSensitiveConfigs(configs); + var kvList = new List>(); foreach (var config in configs) kvList.Add(new KeyValuePair(_configService.GenerateKey(config), config.Value)); @@ -711,14 +751,20 @@ public async Task GetJson(string appId, EnvString env) var configs = await _configService.GetByAppIdAsync(appId, env.Value); // When producing JSON, exclude deleted configurations. configs = configs.Where(x => x.EditStatus != EditStatus.Deleted).ToList(); - var dict = new Dictionary(); + + await MaskSensitiveConfigs(configs); + + var values = new Dictionary(); + var descriptions = new Dictionary(); configs.ForEach(x => { var key = _configService.GenerateKey(x); - dict.Add(key, x.Value); + values.Add(key, x.Value); + if (!string.IsNullOrWhiteSpace(x.Description)) + descriptions.Add(key, x.Description); }); - var json = DictionaryConvertToJson.ToJson(dict); + var json = DictionaryConvertToJsonC.ToJsonC(values, descriptions); return Json(new { @@ -738,7 +784,7 @@ public async Task SaveJson([FromBody] SaveJsonVM data, string app if (string.IsNullOrEmpty(data.json)) throw new ArgumentNullException("data.json"); - var result = await _configService.SaveJsonAsync(data.json, appId, env.Value, data.isPatch); + var result = await _configService.SaveJsonCAsync(data.json, appId, env.Value, data.isPatch); return Json(new { diff --git a/src/AgileConfig.Server.Apisite/Controllers/api/Models/ApiConfigVM.cs b/src/AgileConfig.Server.Apisite/Controllers/api/Models/ApiConfigVM.cs index 3208d509..56cefa39 100644 --- a/src/AgileConfig.Server.Apisite/Controllers/api/Models/ApiConfigVM.cs +++ b/src/AgileConfig.Server.Apisite/Controllers/api/Models/ApiConfigVM.cs @@ -46,6 +46,11 @@ public class ApiConfigVM : IAppIdModel /// public string Description { get; set; } + /// + /// Whether this configuration item contains sensitive data. + /// + public bool Sensitive { get; set; } + /// /// Application ID. /// @@ -74,7 +79,8 @@ public static ConfigVM ToConfigVM(this ApiConfigVM model) Group = model.Group, Key = model.Key, Value = model.Value, - Description = model.Description + Description = model.Description, + Sensitive = model.Sensitive }; } } \ No newline at end of file diff --git a/src/AgileConfig.Server.Apisite/Models/ConfigVM.cs b/src/AgileConfig.Server.Apisite/Models/ConfigVM.cs index 7ec23617..6492b5c9 100644 --- a/src/AgileConfig.Server.Apisite/Models/ConfigVM.cs +++ b/src/AgileConfig.Server.Apisite/Models/ConfigVM.cs @@ -27,6 +27,8 @@ public class ConfigVM : IAppIdModel [MaxLength(200, ErrorMessage = "描述长度不能超过200位")] public string Description { get; set; } + public bool Sensitive { get; set; } + public OnlineStatus OnlineStatus { get; set; } public ConfigStatus Status { get; set; } diff --git a/src/AgileConfig.Server.Apisite/Models/Mapping/ModelMappingExtension.cs b/src/AgileConfig.Server.Apisite/Models/Mapping/ModelMappingExtension.cs index 700d6fb6..71fe5ec0 100644 --- a/src/AgileConfig.Server.Apisite/Models/Mapping/ModelMappingExtension.cs +++ b/src/AgileConfig.Server.Apisite/Models/Mapping/ModelMappingExtension.cs @@ -145,7 +145,9 @@ public static ApiConfigVM ToApiConfigVM(this Config config) Value = config.Value, Status = config.Status, OnlineStatus = config.OnlineStatus, - EditStatus = config.EditStatus + EditStatus = config.EditStatus, + Description = config.Description, + Sensitive = config.Sensitive }; return vm; diff --git a/src/AgileConfig.Server.Common/DictionaryConvertToJsonC.cs b/src/AgileConfig.Server.Common/DictionaryConvertToJsonC.cs new file mode 100644 index 00000000..a5de88b4 --- /dev/null +++ b/src/AgileConfig.Server.Common/DictionaryConvertToJsonC.cs @@ -0,0 +1,219 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace AgileConfig.Server.Common; + +/// +/// Serialize configuration flat dictionaries into JSONC (JSON with Comments) format. +/// Each leaf value is followed by an inline comment containing its description. +/// +public static class DictionaryConvertToJsonC +{ + public static string ToJsonC( + IDictionary values, + IDictionary descriptions) + { + if (values.Count == 0) return "{}"; + + var root = new SortedDictionary(); + foreach (var kv in values) + Generate(kv.Key, kv.Value, root); + + var sb = new StringBuilder(); + WriteJsonC(root, descriptions, sb, 0, ""); + // Remove trailing newline for cleaner output + var result = sb.ToString(); + if (result.EndsWith(Environment.NewLine)) + result = result.Substring(0, result.Length - Environment.NewLine.Length); + return result; + } + + /// + /// Expand flattened key-value pairs into nested dictionaries. + /// Same logic as DictionaryConvertToJson.Generate. + /// + private static void Generate(string key, string value, IDictionary parent) + { + if (string.IsNullOrWhiteSpace(key)) throw new ArgumentNullException(nameof(key)); + + var groupArr = key.Split(':'); + if (groupArr.Length > 1) + { + var sonKey = groupArr[0]; + var newArr = new string[groupArr.Length - 1]; + for (var i = 1; i < groupArr.Length; i++) newArr[i - 1] = groupArr[i]; + var otherKeys = string.Join(':', newArr); + if (parent.ContainsKey(sonKey)) + { + var son = parent[sonKey] as IDictionary; + if (son != null) Generate(otherKeys, value, son); + } + else + { + var son = new SortedDictionary(); + Generate(otherKeys, value, son); + parent.Add(sonKey, son); + } + } + else + { + parent.Add(key, value); + } + } + + /// + /// Recursively build the JSONC string with inline comments for leaf values. + /// + private static void WriteJsonC( + object node, + IDictionary descriptions, + StringBuilder sb, + int indent, + string currentPath) + { + var indentStr = new string(' ', indent * 2); + + if (node is IDictionary dict) + { + // Check if this dictionary can be represented as an array + if (JudgeDictIsJsonArray(dict)) + { + var array = ConvertDictToJsonArray(dict); + sb.AppendLine("["); + for (var i = 0; i < array.Length; i++) + { + var itemPath = string.IsNullOrEmpty(currentPath) + ? i.ToString() + : currentPath + ":" + i; + sb.Append(new string(' ', (indent + 1) * 2)); + WriteJsonCValue(array[i], descriptions, sb, indent + 1, itemPath); + if (i < array.Length - 1) sb.AppendLine(","); + else sb.AppendLine(); + } + sb.Append(indentStr + "]"); + } + else + { + sb.AppendLine("{"); + var keys = dict.Keys.ToList(); + for (var i = 0; i < keys.Count; i++) + { + var key = keys[i]; + var val = dict[key]; + var childPath = string.IsNullOrEmpty(currentPath) + ? key + : currentPath + ":" + key; + + sb.Append(new string(' ', (indent + 1) * 2)); + sb.Append($"\"{EscapeJsonString(key)}\": "); + WriteJsonCValue(val, descriptions, sb, indent + 1, childPath); + if (i < keys.Count - 1) sb.AppendLine(","); + else sb.AppendLine(); + } + sb.Append(indentStr + "}"); + } + } + } + + private static void WriteJsonCValue( + object val, + IDictionary descriptions, + StringBuilder sb, + int indent, + string currentPath) + { + if (val is IDictionary childDict) + { + WriteJsonC(childDict, descriptions, sb, indent, currentPath); + } + else if (val is object[] childArray) + { + sb.AppendLine("["); + for (var i = 0; i < childArray.Length; i++) + { + var itemPath = currentPath + ":" + i; + sb.Append(new string(' ', (indent + 1) * 2)); + WriteJsonCValue(childArray[i], descriptions, sb, indent + 1, itemPath); + if (i < childArray.Length - 1) sb.AppendLine(","); + else sb.AppendLine(); + } + sb.Append(new string(' ', indent * 2) + "]"); + } + else + { + // Leaf value — serialize and append comment if description exists + var jsonValue = SerializeJsonValue(val); + sb.Append(jsonValue); + + if (descriptions.TryGetValue(currentPath, out var desc) && !string.IsNullOrWhiteSpace(desc)) + { + // Sanitize description: remove newlines and escape */ to avoid breaking JSONC structure + var safeDesc = SanitizeComment(desc); + sb.Append(" // " + safeDesc); + } + } + } + + private static string SerializeJsonValue(object val) + { + if (val == null) return "null"; + if (val is string s) return $"\"{EscapeJsonString(s)}\""; + if (val is bool b) return b ? "true" : "false"; + return val.ToString(); + } + + private static string EscapeJsonString(string str) + { + return str + .Replace("\\", "\\\\") + .Replace("\"", "\\\"") + .Replace("\n", "\\n") + .Replace("\r", "\\r") + .Replace("\t", "\\t"); + } + + /// + /// Sanitize a comment string to prevent it from breaking the JSONC structure. + /// + private static string SanitizeComment(string comment) + { + return comment + .Replace("\r\n", " ") + .Replace("\n", " ") + .Replace("\r", " ") + .Replace("*/", "* /"); + } + + /// + /// Determine whether the dictionary represents a JSON array. + /// + private static bool JudgeDictIsJsonArray(IDictionary dict) + { + var keys = dict.Keys; + for (var i = 0; i < keys.Count; i++) + { + var key = i.ToString(); + if (!dict.ContainsKey(key)) return false; + } + + return true; + } + + /// + /// Convert the dictionary to an array. + /// + private static object[] ConvertDictToJsonArray(IDictionary dict) + { + var keys = dict.Keys; + var array = new object[keys.Count()]; + for (var i = 0; i < keys.Count(); i++) + { + var key = i.ToString(); + array[i] = dict[key]; + } + + return array; + } +} diff --git a/src/AgileConfig.Server.Common/JsonCConfigurationFileParser.cs b/src/AgileConfig.Server.Common/JsonCConfigurationFileParser.cs new file mode 100644 index 00000000..8350ce20 --- /dev/null +++ b/src/AgileConfig.Server.Common/JsonCConfigurationFileParser.cs @@ -0,0 +1,107 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Newtonsoft.Json; + +namespace AgileConfig.Server.Common; + +/// +/// Parse JSONC (JSON with Comments) format. +/// Extracts both values and inline comments, returning them as two separate dictionaries +/// keyed by the flattened configuration path (e.g., "group:key"). +/// Uses Newtonsoft.Json's JsonTextReader to walk tokens and capture comment tokens. +/// +public class JsonCConfigurationFileParser +{ + private readonly Stack _path = new(); + + private JsonCConfigurationFileParser() + { + } + + /// + /// Parse a JSONC string and return the flattened key-value pairs along with any inline comments. + /// + /// JSONC string to parse. Supports // and /**/ comments. + /// + /// A tuple containing: + /// - values: flattened key-value dictionary + /// - descriptions: flattened key-description dictionary (extracted from comments) + /// + public static (Dictionary values, Dictionary descriptions) Parse(string json) + { + return new JsonCConfigurationFileParser().ParseString(json); + } + + /// + /// Parse a JSONC stream and return the flattened key-value pairs along with any inline comments. + /// + /// Stream containing JSONC data. + /// + /// A tuple containing: + /// - values: flattened key-value dictionary + /// - descriptions: flattened key-description dictionary (extracted from comments) + /// + public static (Dictionary values, Dictionary descriptions) Parse(Stream input) + { + using var reader = new StreamReader(input); + var json = reader.ReadToEnd(); + return Parse(json); + } + + private (Dictionary values, Dictionary descriptions) ParseString(string json) + { + var values = new Dictionary(); + var descriptions = new Dictionary(); + string lastValueKey = null; + + using var reader = new JsonTextReader(new StringReader(json)) + { + DateParseHandling = DateParseHandling.None + }; + + while (reader.Read()) + { + switch (reader.TokenType) + { + case JsonToken.Comment: + { + // Comments in JSONC typically appear after a value on the same line, + // so associate the comment with the last value we encountered. + var comment = ((string)reader.Value)?.Trim(); + if (!string.IsNullOrEmpty(comment) && lastValueKey != null) + { + // Only set if not already set (first comment wins for a given key) + if (!descriptions.ContainsKey(lastValueKey)) + descriptions[lastValueKey] = comment; + } + + break; + } + case JsonToken.PropertyName: + { + _path.Push((string)reader.Value); + break; + } + case JsonToken.String: + case JsonToken.Integer: + case JsonToken.Float: + case JsonToken.Boolean: + case JsonToken.Null: + { + var key = string.Join(":", _path.Reverse()); + values[key] = reader.Value?.ToString() ?? ""; + lastValueKey = key; + break; + } + case JsonToken.EndObject: + { + if (_path.Count > 0) _path.Pop(); + break; + } + } + } + + return (values, descriptions); + } +} diff --git a/src/AgileConfig.Server.Data.Entity/Config.cs b/src/AgileConfig.Server.Data.Entity/Config.cs index ac8430b0..be644fce 100644 --- a/src/AgileConfig.Server.Data.Entity/Config.cs +++ b/src/AgileConfig.Server.Data.Entity/Config.cs @@ -57,6 +57,9 @@ public class Config : IEntity [Column(Name = "description", StringLength = 200)] public string Description { get; set; } + [Column(Name = "sensitive")] + public bool Sensitive { get; set; } + [Column(Name = "create_time")] [BsonDateTimeOptions(Kind = DateTimeKind.Local)] public DateTime CreateTime { get; set; } diff --git a/src/AgileConfig.Server.Data.Entity/PublishDetail.cs b/src/AgileConfig.Server.Data.Entity/PublishDetail.cs index 0fff6b0c..cfecd1af 100644 --- a/src/AgileConfig.Server.Data.Entity/PublishDetail.cs +++ b/src/AgileConfig.Server.Data.Entity/PublishDetail.cs @@ -29,6 +29,9 @@ public class PublishDetail : IEntity [Column(Name = "description", StringLength = 200)] public string Description { get; set; } + [Column(Name = "sensitive")] + public bool Sensitive { get; set; } + [Column(Name = "edit_status")] public EditStatus EditStatus { get; set; } [Column(Name = "env", StringLength = 50)] diff --git a/src/AgileConfig.Server.IService/IConfigService.cs b/src/AgileConfig.Server.IService/IConfigService.cs index cba1cbe8..91c065c9 100644 --- a/src/AgileConfig.Server.IService/IConfigService.cs +++ b/src/AgileConfig.Server.IService/IConfigService.cs @@ -215,6 +215,17 @@ public interface IConfigService : IDisposable /// Task SaveJsonAsync(string json, string appId, string env, bool isPatch); + /// + /// Convert JSONC (JSON with Comments) configuration into standard config entries and persist them. + /// Inline comments are extracted and stored as Description on each config item. + /// + /// JSONC string with optional inline comments. + /// Application ID. + /// Environment name. + /// Indicates whether to apply patch mode updates. + /// + Task SaveJsonCAsync(string jsonC, string appId, string env, bool isPatch); + /// /// Persist the key/value configuration list to the database. /// diff --git a/src/AgileConfig.Server.IService/IPermissionService.cs b/src/AgileConfig.Server.IService/IPermissionService.cs index c01b6024..ba061a44 100644 --- a/src/AgileConfig.Server.IService/IPermissionService.cs +++ b/src/AgileConfig.Server.IService/IPermissionService.cs @@ -16,6 +16,8 @@ public static class Functions public const string Config_Edit = "CONFIG_EDIT"; public const string Config_Delete = "CONFIG_DELETE"; + public const string Config_ViewSensitive = "CONFIG_VIEW_SENSITIVE"; + public const string Config_Publish = "CONFIG_PUBLISH"; public const string Config_Offline = "CONFIG_OFFLINE"; @@ -62,6 +64,7 @@ public static List GetAllPermissions() Config_Delete, Config_Publish, Config_Offline, + Config_ViewSensitive, // Node permissions Node_Read, diff --git a/src/AgileConfig.Server.Service/ConfigService.cs b/src/AgileConfig.Server.Service/ConfigService.cs index 92ce8a35..3f7e4e40 100644 --- a/src/AgileConfig.Server.Service/ConfigService.cs +++ b/src/AgileConfig.Server.Service/ConfigService.cs @@ -420,7 +420,8 @@ public void Dispose() Value = x.Value, PublishTimelineId = publishTimelineNode.Id, Version = publishTimelineNode.Version, - Env = env + Env = env, + Sensitive = x.Sensitive }); if (x.EditStatus == EditStatus.Deleted) @@ -719,7 +720,9 @@ public async Task EnvSync(string appId, string currentEnv, List to else { // Update the target environment when values differ. - if (envConfig.Value != currentEnvConfig.Value) + if (envConfig.Value != currentEnvConfig.Value + || envConfig.Description != currentEnvConfig.Description + || envConfig.Sensitive != currentEnvConfig.Sensitive) { envConfig.UpdateTime = DateTime.Now; envConfig.Value = currentEnvConfig.Value; @@ -727,6 +730,7 @@ public async Task EnvSync(string appId, string currentEnv, List to envConfig.OnlineStatus = OnlineStatus.WaitPublish; envConfig.Description = currentEnvConfig.Description; + envConfig.Sensitive = currentEnvConfig.Sensitive; updateRanges.Add(envConfig); } } @@ -775,6 +779,26 @@ public async Task SaveJsonAsync(string json, string appId, string env, boo return await SaveFromDictAsync(dict, appId, env, isPatch); } + /// + /// Convert JSONC (JSON with Comments) configuration into standard config entries and persist them. + /// Inline comments are extracted and stored as Description on each config item. + /// + /// JSONC string with optional inline comments. + /// Application ID. + /// Environment name. + /// Indicates whether to apply patch mode updates. + /// True when the JSONC content is processed successfully. + public async Task SaveJsonCAsync(string jsonC, string appId, string env, bool isPatch) + { + if (string.IsNullOrEmpty(jsonC)) throw new ArgumentNullException(nameof(jsonC)); + + var parseResult = JsonCConfigurationFileParser.Parse(jsonC); + var values = parseResult.values; + var descriptions = parseResult.descriptions; + + return await SaveFromDictAsync(values, appId, env, isPatch, descriptions); + } + public (bool, string) ValidateKvString(string kvStr) { var sr = new StringReader(kvStr); @@ -925,7 +949,8 @@ private void ClearAppPublishTimelineVirtualIdCache(string appId, string env) _memoryCache?.Remove(cacheKey); } - private async Task SaveFromDictAsync(IDictionary dict, string appId, string env, bool isPatch) + private async Task SaveFromDictAsync(IDictionary dict, string appId, string env, bool isPatch, + IDictionary descriptions = null) { using var uow = _uowAccessor(env); using var configRepository = _configRepositoryAccessor(env); @@ -961,7 +986,8 @@ private async Task SaveFromDictAsync(IDictionary dict, str CreateTime = now, Status = ConfigStatus.Enabled, EditStatus = EditStatus.Add, - OnlineStatus = OnlineStatus.WaitPublish + OnlineStatus = OnlineStatus.WaitPublish, + Description = descriptions != null && descriptions.TryGetValue(key, out var desc) ? desc : "" }); } else if (config.Value != kv.Value) @@ -990,8 +1016,22 @@ private async Task SaveFromDictAsync(IDictionary dict, str config.EditStatus = EditStatus.Edit; } + // Update description if provided; otherwise keep existing + if (descriptions != null && descriptions.TryGetValue(key, out var desc)) + config.Description = desc; + updateConfigs.Add(config); } + else + { + // Value unchanged, but description may have changed + if (descriptions != null && descriptions.TryGetValue(key, out var desc) + && config.Description != desc) + { + config.Description = desc; + updateConfigs.Add(config); + } + } } if (!isPatch) // In full update mode remove missing configurations; patch mode preserves them. diff --git a/src/AgileConfig.Server.UI/react-ui-antd/src/locales/en-US/pages.ts b/src/AgileConfig.Server.UI/react-ui-antd/src/locales/en-US/pages.ts index 2dc51677..acd52bcc 100644 --- a/src/AgileConfig.Server.UI/react-ui-antd/src/locales/en-US/pages.ts +++ b/src/AgileConfig.Server.UI/react-ui-antd/src/locales/en-US/pages.ts @@ -184,6 +184,10 @@ export default { 'pages.configs.table.cols.k': 'Key', 'pages.configs.table.cols.v': 'Value', 'pages.configs.table.cols.desc': 'Description', + 'pages.configs.table.cols.sensitive': 'Sensitive', + 'pages.configs.sensitive.on': 'Yes', + 'pages.configs.sensitive.off': 'No', + 'pages.configs.sensitive.locked': 'Sensitive config, value is hidden from unauthorized users', 'pages.configs.table.cols.create_time': 'CreateTime', 'pages.configs.table.cols.status': 'Status', 'pages.configs.table.cols.status.0': 'WaitPublish', diff --git a/src/AgileConfig.Server.UI/react-ui-antd/src/locales/zh-CN/pages.ts b/src/AgileConfig.Server.UI/react-ui-antd/src/locales/zh-CN/pages.ts index d8cdefcc..641489ed 100644 --- a/src/AgileConfig.Server.UI/react-ui-antd/src/locales/zh-CN/pages.ts +++ b/src/AgileConfig.Server.UI/react-ui-antd/src/locales/zh-CN/pages.ts @@ -181,6 +181,10 @@ export default { 'pages.configs.table.cols.k': '键', 'pages.configs.table.cols.v': '值', 'pages.configs.table.cols.desc': '描述', + 'pages.configs.table.cols.sensitive': '敏感', + 'pages.configs.sensitive.on': '是', + 'pages.configs.sensitive.off': '否', + 'pages.configs.sensitive.locked': '此配置为敏感配置,无权限用户无法查看值', 'pages.configs.table.cols.create_time': '创建时间', 'pages.configs.table.cols.status': '状态', 'pages.configs.table.cols.status.0': '待发布', diff --git a/src/AgileConfig.Server.UI/react-ui-antd/src/models/functionKeys.ts b/src/AgileConfig.Server.UI/react-ui-antd/src/models/functionKeys.ts index 1033426d..3c4cdf7c 100644 --- a/src/AgileConfig.Server.UI/react-ui-antd/src/models/functionKeys.ts +++ b/src/AgileConfig.Server.UI/react-ui-antd/src/models/functionKeys.ts @@ -13,6 +13,7 @@ export default { Config_Delete: 'CONFIG_DELETE', Config_Publish: 'CONFIG_PUBLISH', Config_Offline: 'CONFIG_OFFLINE', + Config_ViewSensitive: 'CONFIG_VIEW_SENSITIVE', // Node Node_Read: 'NODE_READ', diff --git a/src/AgileConfig.Server.UI/react-ui-antd/src/pages/Configs/comps/JsonEditor.tsx b/src/AgileConfig.Server.UI/react-ui-antd/src/pages/Configs/comps/JsonEditor.tsx index d421bfb0..e89f6b31 100644 --- a/src/AgileConfig.Server.UI/react-ui-antd/src/pages/Configs/comps/JsonEditor.tsx +++ b/src/AgileConfig.Server.UI/react-ui-antd/src/pages/Configs/comps/JsonEditor.tsx @@ -116,7 +116,7 @@ const JsonEditor: React.FC = (props) => { > = (props)=>{ label={intl.formatMessage({id:'pages.configs.table.cols.desc'})} name="description" /> - + + ); } diff --git a/src/AgileConfig.Server.UI/react-ui-antd/src/pages/Configs/data.d.ts b/src/AgileConfig.Server.UI/react-ui-antd/src/pages/Configs/data.d.ts index d67486b1..bac1ab6c 100644 --- a/src/AgileConfig.Server.UI/react-ui-antd/src/pages/Configs/data.d.ts +++ b/src/AgileConfig.Server.UI/react-ui-antd/src/pages/Configs/data.d.ts @@ -10,6 +10,7 @@ export type ConfigListItem = { status: number updateTime: Date value: string + sensitive: boolean } export type ConfigModifyLog = { diff --git a/src/AgileConfig.Server.UI/react-ui-antd/src/pages/Configs/index.tsx b/src/AgileConfig.Server.UI/react-ui-antd/src/pages/Configs/index.tsx index 08bc48f0..bdf9694d 100644 --- a/src/AgileConfig.Server.UI/react-ui-antd/src/pages/Configs/index.tsx +++ b/src/AgileConfig.Server.UI/react-ui-antd/src/pages/Configs/index.tsx @@ -1,5 +1,5 @@ import { DeleteOutlined, DownOutlined, PlusOutlined, RollbackOutlined, VerticalAlignTopOutlined } from '@ant-design/icons'; -import { ModalForm, ProFormText, ProFormTextArea } from '@ant-design/pro-form'; +import { ModalForm, ProFormText, ProFormTextArea, ProFormSwitch } from '@ant-design/pro-form'; import { PageContainer } from '@ant-design/pro-layout'; import ProTable, { ActionType, ProColumns, TableDropdown } from '@ant-design/pro-table'; import { Badge, Button, Drawer, Dropdown, FormInstance, Input, List, Menu, message, Modal, Radio, Space, Tag } from 'antd'; @@ -331,6 +331,15 @@ const configs: React.FC = (props: any) => { hideInSearch: true, ellipsis: true, }, + { + title: intl.formatMessage({id:'pages.configs.table.cols.sensitive'}), + dataIndex: 'sensitive', + hideInSearch: true, + width: 70, + render: (_, record) => ( + record.sensitive ? 🔒 : null + ), + }, { title: intl.formatMessage({id:'pages.configs.table.cols.create_time'}), dataIndex: 'createTime', @@ -781,6 +790,12 @@ const configs: React.FC = (props: any) => { label={intl.formatMessage({id:'pages.configs.table.cols.desc'})} name="description" /> + { updateModalVisible && diff --git a/test/AgileConfig.Server.CommonTests/DictionaryConvertToJsonCTests.cs b/test/AgileConfig.Server.CommonTests/DictionaryConvertToJsonCTests.cs new file mode 100644 index 00000000..a82a0da2 --- /dev/null +++ b/test/AgileConfig.Server.CommonTests/DictionaryConvertToJsonCTests.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace AgileConfig.Server.Common.Tests; + +[TestClass] +public class DictionaryConvertToJsonCTests +{ + [TestMethod] + public void ToJsonC_BasicTest() + { + var values = new Dictionary + { + { "a", "1" } + }; + var descriptions = new Dictionary + { + { "a", "simple value" } + }; + var json = DictionaryConvertToJsonC.ToJsonC(values, descriptions); + Assert.IsNotNull(json); + Assert.IsTrue(json.Contains("\"a\": \"1\" // simple value")); + Console.WriteLine(json); + } + + [TestMethod] + public void ToJsonC_NoDescriptionTest() + { + var values = new Dictionary + { + { "a", "1" }, + { "b", "2" } + }; + var descriptions = new Dictionary(); + var json = DictionaryConvertToJsonC.ToJsonC(values, descriptions); + Assert.IsNotNull(json); + Assert.IsTrue(json.Contains("\"a\": \"1\"")); + Assert.IsTrue(json.Contains("\"b\": \"2\"")); + // No comments should be present + Assert.IsFalse(json.Contains("//")); + Console.WriteLine(json); + } + + [TestMethod] + public void ToJsonC_NestedTest() + { + var values = new Dictionary + { + { "oss:custom_domain", "oss-ruzhou-web.rzshow.com" } + }; + var descriptions = new Dictionary + { + { "oss:custom_domain", "自定义域名" } + }; + var json = DictionaryConvertToJsonC.ToJsonC(values, descriptions); + Assert.IsNotNull(json); + Assert.IsTrue(json.Contains("// 自定义域名")); + Console.WriteLine(json); + } + + [TestMethod] + public void ToJsonC_MultipleNestedTest() + { + var values = new Dictionary + { + { "oss:custom_domain", "oss-ruzhou-web.rzshow.com" }, + { "oss:bucket", "my-bucket" }, + { "db:host", "localhost" } + }; + var descriptions = new Dictionary + { + { "oss:custom_domain", "自定义域名" }, + { "oss:bucket", "存储桶名称" }, + { "db:host", "数据库地址" } + }; + var json = DictionaryConvertToJsonC.ToJsonC(values, descriptions); + Assert.IsNotNull(json); + Assert.IsTrue(json.Contains("// 自定义域名")); + Assert.IsTrue(json.Contains("// 存储桶名称")); + Assert.IsTrue(json.Contains("// 数据库地址")); + Console.WriteLine(json); + } + + [TestMethod] + public void ToJsonC_SensitiveDescriptionsTest() + { + var values = new Dictionary + { + { "secret:key", "abc123" } + }; + var descriptions = new Dictionary + { + { "secret:key", "very secret */ don't break" } + }; + var json = DictionaryConvertToJsonC.ToJsonC(values, descriptions); + Assert.IsNotNull(json); + // The */ should be sanitized to prevent breaking the JSONC structure + Assert.IsTrue(json.Contains("// very secret * / don't break")); + Assert.IsFalse(json.Contains("*/")); + Console.WriteLine(json); + } + + [TestMethod] + public void ToJsonC_EmptyDictTest() + { + var values = new Dictionary(); + var descriptions = new Dictionary(); + var json = DictionaryConvertToJsonC.ToJsonC(values, descriptions); + Assert.AreEqual("{}", json); + } + + [TestMethod] + public void ToJsonC_ArrayTest() + { + var values = new Dictionary + { + { "arr:0", "1" }, + { "arr:1", "2" }, + { "arr:2", "3" } + }; + var descriptions = new Dictionary + { + { "arr:0", "first" }, + { "arr:1", "second" } + }; + var json = DictionaryConvertToJsonC.ToJsonC(values, descriptions); + Assert.IsNotNull(json); + Assert.IsTrue(json.Contains("// first")); + Assert.IsTrue(json.Contains("// second")); + Console.WriteLine(json); + } +} diff --git a/test/AgileConfig.Server.CommonTests/JsonCConfigurationFileParserTests.cs b/test/AgileConfig.Server.CommonTests/JsonCConfigurationFileParserTests.cs new file mode 100644 index 00000000..1e49644d --- /dev/null +++ b/test/AgileConfig.Server.CommonTests/JsonCConfigurationFileParserTests.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace AgileConfig.Server.Common.Tests; + +[TestClass] +public class JsonCConfigurationFileParserTests +{ + [TestMethod] + public void Parse_BasicJsonTest() + { + var json = @"{ + ""a"": ""1"" +}"; + var result = JsonCConfigurationFileParser.Parse(json); + Assert.AreEqual(1, result.values.Count); + Assert.AreEqual("1", result.values["a"]); + Assert.AreEqual(0, result.descriptions.Count); + } + + [TestMethod] + public void Parse_WithCommentsTest() + { + var json = @"{ + ""oss"": { + ""custom_domain"": ""oss-ruzhou-web.rzshow.com"" // 自定义域名 + } +}"; + var result = JsonCConfigurationFileParser.Parse(json); + Assert.AreEqual(1, result.values.Count); + Assert.AreEqual("oss-ruzhou-web.rzshow.com", result.values["oss:custom_domain"]); + Assert.AreEqual(1, result.descriptions.Count); + Assert.AreEqual("自定义域名", result.descriptions["oss:custom_domain"]); + } + + [TestMethod] + public void Parse_MultipleKeysWithCommentsTest() + { + var json = @"{ + ""oss"": { + ""custom_domain"": ""oss-ruzhou-web.rzshow.com"" // 自定义域名 + }, + ""db"": { + ""host"": ""localhost"" // 数据库地址 + } +}"; + var result = JsonCConfigurationFileParser.Parse(json); + Assert.AreEqual(2, result.values.Count); + Assert.AreEqual("oss-ruzhou-web.rzshow.com", result.values["oss:custom_domain"]); + Assert.AreEqual("localhost", result.values["db:host"]); + Assert.AreEqual(2, result.descriptions.Count); + Assert.AreEqual("自定义域名", result.descriptions["oss:custom_domain"]); + Assert.AreEqual("数据库地址", result.descriptions["db:host"]); + } + + [TestMethod] + public void Parse_MixedCommentsTest() + { + var json = @"{ + ""name"": ""test"", // 名称 + ""version"": ""1.0"" // 版本号 +}"; + var result = JsonCConfigurationFileParser.Parse(json); + Assert.AreEqual(2, result.values.Count); + Assert.AreEqual(1, result.descriptions.Count); // Should capture name comment, version comment might be affected by comma + // At minimum, the name comment should be captured + Console.WriteLine($"Descriptions count: {result.descriptions.Count}"); + foreach (var kv in result.descriptions) + Console.WriteLine($" {kv.Key}: {kv.Value}"); + } + + [TestMethod] + public void Parse_NoCommentsTest() + { + var json = @"{ + ""key1"": ""value1"", + ""key2"": ""value2"" +}"; + var result = JsonCConfigurationFileParser.Parse(json); + Assert.AreEqual(2, result.values.Count); + Assert.AreEqual(0, result.descriptions.Count); + } + + [TestMethod] + public void Parse_NestedNoCommentsTest() + { + var json = @"{ + ""oss"": { + ""custom_domain"": ""oss-ruzhou-web.rzshow.com"" + } +}"; + var result = JsonCConfigurationFileParser.Parse(json); + Assert.AreEqual(1, result.values.Count); + Assert.AreEqual("oss-ruzhou-web.rzshow.com", result.values["oss:custom_domain"]); + Assert.AreEqual(0, result.descriptions.Count); + } + + [TestMethod] + public void Parse_StreamTest() + { + var json = @"{ + ""oss"": { + ""custom_domain"": ""oss-ruzhou-web.rzshow.com"" // 自定义域名 + } +}"; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + var result = JsonCConfigurationFileParser.Parse(stream); + Assert.AreEqual(1, result.values.Count); + Assert.AreEqual(1, result.descriptions.Count); + Assert.AreEqual("自定义域名", result.descriptions["oss:custom_domain"]); + } + + [TestMethod] + public void Parse_BlockCommentTest() + { + var json = @"{ + /* 配置信息 */ + ""key"": ""value"" // 行内注释 +}"; + var result = JsonCConfigurationFileParser.Parse(json); + Assert.AreEqual(1, result.values.Count); + Assert.AreEqual("value", result.values["key"]); + // Block comment before key should not be captured (parser associates with last value) + // Inline comment should be captured + Assert.AreEqual(1, result.descriptions.Count); + } + + [TestMethod] + public void Parse_EmptyJsonTest() + { + var json = "{}"; + var result = JsonCConfigurationFileParser.Parse(json); + Assert.AreEqual(0, result.values.Count); + Assert.AreEqual(0, result.descriptions.Count); + } + + [TestMethod] + public void Parse_NumericAndBooleanValuesTest() + { + var json = @"{ + ""port"": 8080, // 端口号 + ""enabled"": true, // 是否启用 + ""ratio"": 0.5 +}"; + var result = JsonCConfigurationFileParser.Parse(json); + Assert.AreEqual(3, result.values.Count); + Assert.AreEqual("8080", result.values["port"]); + Assert.AreEqual("True", result.values["enabled"]); + Assert.AreEqual("0.5", result.values["ratio"]); + } + + [TestMethod] + public void Parse_RoundTripTest() + { + // Test round-trip: values + descriptions → JSONC → values + descriptions + var values = new Dictionary + { + { "oss:custom_domain", "oss-ruzhou-web.rzshow.com" }, + { "db:host", "localhost" } + }; + var descriptions = new Dictionary + { + { "oss:custom_domain", "自定义域名" }, + { "db:host", "数据库地址" } + }; + + var jsonC = DictionaryConvertToJsonC.ToJsonC(values, descriptions); + Console.WriteLine("Generated JSONC:"); + Console.WriteLine(jsonC); + + var parseResult = JsonCConfigurationFileParser.Parse(jsonC); + + Assert.AreEqual(values.Count, parseResult.values.Count); + foreach (var kv in values) + Assert.AreEqual(kv.Value, parseResult.values[kv.Key]); + + Assert.AreEqual(descriptions.Count, parseResult.descriptions.Count); + foreach (var kv in descriptions) + Assert.AreEqual(kv.Value, parseResult.descriptions[kv.Key]); + } +} From 408c88601df87153640f5bbb713346a37dc8393a Mon Sep 17 00:00:00 2001 From: GuoHao <897690986@qq.com> Date: Sun, 26 Jul 2026 04:57:50 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E6=95=8F=E6=84=9F=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=80=BC=E9=BB=98=E8=AE=A4=E6=8E=A9=E7=A0=81=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=20+=20CI=20=E6=B5=8B=E8=AF=95=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI优化: - 敏感配置值默认显示为 ******,无论用户是否有权限 - 拥有 CONFIG_VIEW_SENSITIVE 权限的用户旁边显示眼睛图标,点击切换查看/隐藏 - 环境切换时自动清空揭示状态,避免跨环境泄露 - 图标添加中英文 tooltip 提示 CI修复: - ConfigController 新增 IPermissionService 参数,同步更新测试代码 Co-Authored-By: Claude --- .../react-ui-antd/src/locales/en-US/pages.ts | 2 + .../react-ui-antd/src/locales/zh-CN/pages.ts | 2 + .../react-ui-antd/src/pages/Configs/index.tsx | 37 +++++++++++++++++-- test/ApiSiteTests/TestApiConfigController.cs | 6 ++- 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/AgileConfig.Server.UI/react-ui-antd/src/locales/en-US/pages.ts b/src/AgileConfig.Server.UI/react-ui-antd/src/locales/en-US/pages.ts index acd52bcc..30515557 100644 --- a/src/AgileConfig.Server.UI/react-ui-antd/src/locales/en-US/pages.ts +++ b/src/AgileConfig.Server.UI/react-ui-antd/src/locales/en-US/pages.ts @@ -188,6 +188,8 @@ export default { 'pages.configs.sensitive.on': 'Yes', 'pages.configs.sensitive.off': 'No', 'pages.configs.sensitive.locked': 'Sensitive config, value is hidden from unauthorized users', + 'pages.configs.sensitive.view': 'View sensitive value', + 'pages.configs.sensitive.hide': 'Hide sensitive value', 'pages.configs.table.cols.create_time': 'CreateTime', 'pages.configs.table.cols.status': 'Status', 'pages.configs.table.cols.status.0': 'WaitPublish', diff --git a/src/AgileConfig.Server.UI/react-ui-antd/src/locales/zh-CN/pages.ts b/src/AgileConfig.Server.UI/react-ui-antd/src/locales/zh-CN/pages.ts index 641489ed..5971b5ce 100644 --- a/src/AgileConfig.Server.UI/react-ui-antd/src/locales/zh-CN/pages.ts +++ b/src/AgileConfig.Server.UI/react-ui-antd/src/locales/zh-CN/pages.ts @@ -185,6 +185,8 @@ export default { 'pages.configs.sensitive.on': '是', 'pages.configs.sensitive.off': '否', 'pages.configs.sensitive.locked': '此配置为敏感配置,无权限用户无法查看值', + 'pages.configs.sensitive.view': '查看敏感值', + 'pages.configs.sensitive.hide': '隐藏敏感值', 'pages.configs.table.cols.create_time': '创建时间', 'pages.configs.table.cols.status': '状态', 'pages.configs.table.cols.status.0': '待发布', diff --git a/src/AgileConfig.Server.UI/react-ui-antd/src/pages/Configs/index.tsx b/src/AgileConfig.Server.UI/react-ui-antd/src/pages/Configs/index.tsx index bdf9694d..35cf591d 100644 --- a/src/AgileConfig.Server.UI/react-ui-antd/src/pages/Configs/index.tsx +++ b/src/AgileConfig.Server.UI/react-ui-antd/src/pages/Configs/index.tsx @@ -1,4 +1,4 @@ -import { DeleteOutlined, DownOutlined, PlusOutlined, RollbackOutlined, VerticalAlignTopOutlined } from '@ant-design/icons'; +import { DeleteOutlined, DownOutlined, EyeInvisibleOutlined, EyeOutlined, PlusOutlined, RollbackOutlined, VerticalAlignTopOutlined } from '@ant-design/icons'; import { ModalForm, ProFormText, ProFormTextArea, ProFormSwitch } from '@ant-design/pro-form'; import { PageContainer } from '@ant-design/pro-layout'; import ProTable, { ActionType, ProColumns, TableDropdown } from '@ant-design/pro-table'; @@ -230,10 +230,14 @@ const configs: React.FC = (props: any) => { const envList = getEnvList(); const [currentEnv, setCurrentEnv] = useState(envList[0]); const [tableData, setTableData] = useState([]); + const [revealedSensitiveKeys, setRevealedSensitiveKeys] = useState>({}); const actionRef = useRef(); const addFormRef = useRef(); let _publishLog:string = ''; const intl = useIntl(); + const toggleReveal = (id: string) => { + setRevealedSensitiveKeys(prev => ({ ...prev, [id]: !prev[id] })); + }; useEffect(() => { getWaitPublishStatus(appId, currentEnv).then(x => { console.log('WaitPublishStatus ', x); @@ -322,8 +326,34 @@ const configs: React.FC = (props: any) => { dataIndex: 'value', hideInSearch: true, ellipsis: true, - copyable: true, - tip: intl.formatMessage({id:'pages.configs.table.cols.v.tip'}) + tip: intl.formatMessage({id:'pages.configs.table.cols.v.tip'}), + render: (_, record) => { + if (!record.sensitive) { + return {record.value}; + } + const hasPermission = checkUserPermission(getFunctions(), functionKeys.Config_ViewSensitive, appId); + const isRevealed = revealedSensitiveKeys[record.id] === true; + if (hasPermission && isRevealed) { + return ( + + {record.value} + toggleReveal(record.id)} title={intl.formatMessage({id:'pages.configs.sensitive.hide'})}> + + + + ); + } + return ( + + ****** + {hasPermission && ( + toggleReveal(record.id)} title={intl.formatMessage({id:'pages.configs.sensitive.view'})}> + + + )} + + ); + }, }, { title: intl.formatMessage({id:'pages.configs.table.cols.desc'}), @@ -464,6 +494,7 @@ const configs: React.FC = (props: any) => { (e)=>{ console.log(e.target.value); setCurrentEnv(e.target.value); + setRevealedSensitiveKeys({}); actionRef.current?.reload(true); }}> { diff --git a/test/ApiSiteTests/TestApiConfigController.cs b/test/ApiSiteTests/TestApiConfigController.cs index 4688ecfe..80310b76 100644 --- a/test/ApiSiteTests/TestApiConfigController.cs +++ b/test/ApiSiteTests/TestApiConfigController.cs @@ -63,6 +63,7 @@ List newConfigs() var userSErvice = new Mock(); var eventBus = new Mock(); var meterService = new Mock(); + var permissionService = new Mock(); var httpContext = new DefaultHttpContext(); httpContext.Request.Headers["Authorization"] = "Basic MDAxOjE="; @@ -72,7 +73,7 @@ List newConfigs() appService.Object, memoryCache, meterService.Object, - new ConfigController(configService.Object, appService.Object, userSErvice.Object, eventBus.Object) + new ConfigController(configService.Object, appService.Object, userSErvice.Object, eventBus.Object, permissionService.Object) ); ctrl.ControllerContext = new ControllerContext { @@ -105,6 +106,7 @@ App newApp1() var userSErvice = new Mock(); var eventBus = new Mock(); var meterService = new Mock(); + var permissionService = new Mock(); var httpContext = new DefaultHttpContext(); httpContext.Request.Headers["Authorization"] = "Basic MDAxOjE="; @@ -113,7 +115,7 @@ App newApp1() appService.Object, memoryCache, meterService.Object, - new ConfigController(configService.Object, appService.Object, userSErvice.Object, eventBus.Object) + new ConfigController(configService.Object, appService.Object, userSErvice.Object, eventBus.Object, permissionService.Object) ); ctrl.ControllerContext = new ControllerContext {