diff --git a/wwwroot/secrets/delete_user_secret.dspy b/wwwroot/secrets/delete_user_secret.dspy new file mode 100644 index 0000000..9e60296 --- /dev/null +++ b/wwwroot/secrets/delete_user_secret.dspy @@ -0,0 +1,16 @@ +# delete_user_secret.dspy - 删除凭据(仅本人条目;二次确认在前端) +import json +uid = await get_user() +if not uid: + return json.dumps({"success": False, "error": "未登录"}, ensure_ascii=False) +name = str((params_kw or {}).get('name', '') or '').strip() +if not name: + return json.dumps({"success": False, "error": "缺少名称"}, ensure_ascii=False) +org = (await get_userorgid()) or '' +from pipeline_service import secret_vault as sv +async with DBPools().sqlorContext('pipeline') as sor: + r = await sv.delete_secret(sor, name=name, org_id=org, user_id=uid, who=uid) + await sor.sqlExe("COMMIT", {}) +if r.get("ok"): + return json.dumps({"success": True, "message": r.get("message", "已删除")}, ensure_ascii=False) +return json.dumps({"success": False, "error": r.get("message", "删除失败")}, ensure_ascii=False) diff --git a/wwwroot/secrets/get_secret_audit.dspy b/wwwroot/secrets/get_secret_audit.dspy new file mode 100644 index 0000000..f4497ae --- /dev/null +++ b/wwwroot/secrets/get_secret_audit.dspy @@ -0,0 +1,23 @@ +# get_secret_audit.dspy - 我的凭据操作审计(仅本人/本机构相关记录) +import json +uid = await get_user() +if not uid: + return [] +org = (await get_userorgid()) or '' +out = [] +async with DBPools().sqlorContext('pipeline') as sor: + rows = await sor.sqlExe( + "SELECT action, detail, who, exe_timestamp FROM audit_log " + "WHERE (tenant_id=${u}$ OR tenant_id=${o}$) AND action LIKE ${p}$ " + "ORDER BY exe_timestamp DESC LIMIT 100", + {"u": uid, "o": org or '', "p": "secret_%"}) + await sor.sqlExe("COMMIT", {}) +for r in (rows or []): + d = dict(r) + out.append({ + "action": str(d.get("action", "")), + "detail": str(d.get("detail", ""))[:120], + "who": str(d.get("who", "")), + "created_at": str(d.get("exe_timestamp", ""))[:19], + }) +return out diff --git a/wwwroot/secrets/get_user_secrets.dspy b/wwwroot/secrets/get_user_secrets.dspy new file mode 100644 index 0000000..26c63ac --- /dev/null +++ b/wwwroot/secrets/get_user_secrets.dspy @@ -0,0 +1,29 @@ +# get_user_secrets.dspy - 我的凭据列表(元数据,绝不含值/密文) +import json +uid = await get_user() +if not uid: + return [] +org = (await get_userorgid()) or '' +from pipeline_service import secret_vault as sv +rows = [] +async with DBPools().sqlorContext('pipeline') as sor: + rows = await sv.list_secrets(sor, org_id=org, user_id=uid, only_active=False) + await sor.sqlExe("COMMIT", {}) +out = [] +for r in (rows or []): + out.append({ + "id": r.get("id", ""), + "name": r.get("name", ""), + "secret_type": r.get("secret_type", "") or "other", + "label": r.get("label", "") or "", + "remark": r.get("remark", "") or "", + "status": r.get("status", "") or "active", + "prefix_hint": r.get("prefix_hint", "") or "", + "length_hint": r.get("length_hint", 0) or 0, + "use_count": r.get("use_count", 0) or 0, + "source": r.get("source", "") or "", + "owner": "mine" if r.get("user_id") == uid else "org", + "created_at": str(r.get("created_at", "") or "")[:19], + "updated_at": str(r.get("updated_at", "") or "")[:19], + }) +return out diff --git a/wwwroot/secrets/index.ui b/wwwroot/secrets/index.ui new file mode 100644 index 0000000..5150520 --- /dev/null +++ b/wwwroot/secrets/index.ui @@ -0,0 +1,84 @@ +{ + "widgettype": "VBox", + "options": {"cheight": 40, "width": "100%"}, + "subwidgets": [{ + "id": "secret_toolbar", + "widgettype": "HBox", + "options": {"cheight": 2, "gap": "8px", "alignItems": "center"}, + "subwidgets": [{ + "widgettype": "Button", + "id": "btn_new_secret", + "options": {"label": "🔑 新增凭据", "css": "primary", "i18n": true}, + "script": "var r=await fetch('{{entire_url('./new_secret_popup.dspy')}}?op=form');var d=await r.json();if(d){bricks.widgetBuild(d,bricks.app);}" + }, { + "widgettype": "Text", + "options": {"text": "值永不显示(密文存储);改值请删除后重存。", "i18n": true, "cfontsize": 0.85} + }] + }, { + "id": "my_secrets_tbl", + "widgettype": "Tabular", + "options": { + "width": "100%", + "height": "62%", + "title": "我的凭据", + "css": "card", + "editable": { + "delete_data_url": "{{entire_url('./delete_user_secret.dspy')}}", + "update_data_url": "{{entire_url('./update_user_secret.dspy')}}" + }, + "data_url": "{{entire_url('./get_user_secrets.dspy')}}", + "data_method": "GET", + "data_params": {{json.dumps(params_kw, indent=4, ensure_ascii=False)}}, + "row_options": { + "editexclouded": ["id", "prefix_hint", "length_hint", "use_count", "source", "owner", "created_at", "updated_at", "secret_type"], + "fields": [ + {"name": "id", "title": "ID", "type": "str", "length": 32, "cwidth": 0, "uitype": "str", "datatype": "str", "label": "ID"}, + {"name": "name", "title": "名称", "type": "str", "length": 64, "cwidth": 16, "uitype": "str", "datatype": "str", "label": "名称"}, + {"name": "secret_type", "title": "类型", "type": "str", "length": 32, "cwidth": 10, "uitype": "str", "datatype": "str", "label": "类型"}, + {"name": "label", "title": "标签", "type": "str", "length": 128, "cwidth": 14, "uitype": "str", "datatype": "str", "label": "标签"}, + {"name": "remark", "title": "备注", "type": "str", "length": 255, "cwidth": 20, "uitype": "str", "datatype": "str", "label": "备注"}, + {"name": "status", "title": "状态", "type": "str", "length": 16, "cwidth": 8, "uitype": "code", "datatype": "str", "label": "状态", + "valueField": "value", "textField": "text", + "options": [{"value": "active", "text": "启用"}, {"value": "disabled", "text": "停用"}]}, + {"name": "prefix_hint", "title": "前缀", "type": "str", "length": 8, "cwidth": 8, "uitype": "str", "datatype": "str", "label": "前缀(前4字符)"}, + {"name": "length_hint", "title": "长度", "type": "int", "cwidth": 6, "uitype": "str", "datatype": "int", "label": "长度"}, + {"name": "use_count", "title": "用过", "type": "int", "cwidth": 6, "uitype": "str", "datatype": "int", "label": "使用次数"}, + {"name": "source", "title": "来源", "type": "str", "length": 32, "cwidth": 10, "uitype": "str", "datatype": "str", "label": "来源"}, + {"name": "owner", "title": "归属", "type": "str", "length": 8, "cwidth": 8, "uitype": "str", "datatype": "str", "label": "归属"}, + {"name": "created_at", "title": "创建", "type": "str", "length": 19, "cwidth": 14, "uitype": "str", "datatype": "str", "label": "创建时间"}, + {"name": "updated_at", "title": "更新", "type": "str", "length": 19, "cwidth": 14, "uitype": "str", "datatype": "str", "label": "更新时间"} + ] + } + } + }, { + "id": "secret_help_text", + "widgettype": "Text", + "options": { + "otext": "值永不显示(密文存储)。新增需填名称+值;值不可修改,改值请删除后重存。会话中引用:占位符 @@sec:名称@@;命令中引用:$PIPELINE_SEC_名称。停用后不再注入执行环境。", + "text": "值永不显示(密文存储)。新增需填名称+值;值不可修改,改值请删除后重存。会话中引用:占位符 @@sec:名称@@;命令中引用:$PIPELINE_SEC_名称。停用后不再注入执行环境。", + "i18n": true, + "cfontsize": 0.9 + } + }, { + "id": "my_secret_audit_tbl", + "widgettype": "Tabular", + "options": { + "width": "100%", + "height": "30%", + "title": "操作审计", + "css": "card", + "data_url": "{{entire_url('./get_secret_audit.dspy')}}", + "data_method": "GET", + "data_params": {{json.dumps(params_kw, indent=4, ensure_ascii=False)}}, + "row_options": { + "editexclouded": ["action", "detail", "who", "created_at"], + "fields": [ + {"name": "created_at", "title": "时间", "type": "str", "length": 19, "cwidth": 16, "uitype": "str", "datatype": "str", "label": "时间"}, + {"name": "action", "title": "操作", "type": "str", "length": 32, "cwidth": 16, "uitype": "str", "datatype": "str", "label": "操作"}, + {"name": "detail", "title": "详情", "type": "str", "length": 120, "cwidth": 40, "uitype": "str", "datatype": "str", "label": "详情"}, + {"name": "who", "title": "操作者", "type": "str", "length": 32, "cwidth": 16, "uitype": "str", "datatype": "str", "label": "操作者"} + ] + } + } + }] +} diff --git a/wwwroot/secrets/new_secret_popup.dspy b/wwwroot/secrets/new_secret_popup.dspy new file mode 100644 index 0000000..8c33b8d --- /dev/null +++ b/wwwroot/secrets/new_secret_popup.dspy @@ -0,0 +1,85 @@ +# new_secret_popup.dspy - 新增凭据弹窗(值只经表单 POST,绝不进列表/编辑对话框) +import json + +uid = await get_user() + +if (params_kw or {}).get('op') != 'form': + # POST:保存 + if not uid: + return json.dumps({"success": False, "error": "未登录"}, ensure_ascii=False) + name = str((params_kw or {}).get('name', '') or '').strip() + value = str((params_kw or {}).get('value', '') or '') + label = str((params_kw or {}).get('label', '') or '').strip() + remark = str((params_kw or {}).get('remark', '') or '').strip() + stype = str((params_kw or {}).get('secret_type', '') or '').strip() + if not name or not value: + return json.dumps({"success": False, "error": "名称与值必填"}, ensure_ascii=False) + org = (await get_userorgid()) or '' + from pipeline_service import secret_vault as sv + async with DBPools().sqlorContext('pipeline') as sor: + r = await sv.save_secret(sor, name=name, value=value, org_id=org, user_id=uid, + label=label, secret_type=stype, source='manual', + remark=remark, who=uid) + await sor.sqlExe("COMMIT", {}) + if r.get("ok"): + return json.dumps({"success": True, "message": r.get("message", "已保存")}, + ensure_ascii=False) + return json.dumps({"success": False, "error": r.get("message", "保存失败")}, + ensure_ascii=False) + +# GET op=form:返回弹窗 widget +if not uid: + return json.dumps({"widgettype": "PopupWindow", "id": "new_secret_pw", + "options": {"title": "新增凭据", "cwidth": 26, "cheight": 14, + "auto_open": True}, + "subwidgets": [{"widgettype": "Text", + "options": {"text": "请先登录", "cfontsize": 1}}]}, + ensure_ascii=False) + +_self_url = entire_url("/pipeline_core/secrets/new_secret_popup.dspy") + + +def _field(fid, label, ftype, placeholder=""): + # bricks 控件名:UiStr=单行 / UiPassword=口令掩码 / UiText=多行 textarea + wtype = {"text": "UiStr", "pass": "UiPassword", "area": "UiText"}[ftype] + return {"widgettype": "VBox", "options": {"css": "field-row"}, "subwidgets": [ + {"widgettype": "Text", "options": {"text": label, "cfontsize": 0.9}}, + {"widgettype": wtype, + "id": fid, + "options": {"placeholder": placeholder, + "cheight": 3 if ftype != "area" else 5}}]} + + +_save_js = ( + "var body=new URLSearchParams();" + "body.append('name',bricks.getWidgetById('ns_name',bricks.app).get_value());" + "body.append('value',bricks.getWidgetById('ns_value',bricks.app).get_value());" + "body.append('label',bricks.getWidgetById('ns_label',bricks.app).get_value());" + "body.append('remark',bricks.getWidgetById('ns_remark',bricks.app).get_value());" + "body.append('secret_type',bricks.getWidgetById('ns_type',bricks.app).get_value());" + "var rp=await fetch(" + json.dumps(_self_url) + ",{method:'POST'," + "headers:{'Content-Type':'application/x-www-form-urlencoded'},body:body});" + "var d=await rp.json();" + "if(d&&d.success){bricks.show_message({title:'凭据',message:d.message||'已保存'});" + "var pw=bricks.getWidgetById('new_secret_pw',bricks.app);if(pw){pw.destroy();}" + "location.reload();}" + "else{bricks.show_message({title:'凭据',message:(d&&d.error)||'保存失败'});}" +) + +return json.dumps({ + "widgettype": "PopupWindow", + "id": "new_secret_pw", + "options": {"title": "🔑 新增凭据", "cwidth": 26, "cheight": 20, "auto_open": True}, + "subwidgets": [ + {"widgettype": "Text", "options": { + "text": "值加密存储、永不回显;会话用 @@sec:名称@@ 引用,命令用 $PIPELINE_SEC_名称。", + "cfontsize": 0.85}}, + _field("ns_name", "名称(大写字母/数字/下划线)", "text", "如 GITHUB_TOKEN"), + _field("ns_value", "值(明文,仅本次传输)", "pass", "粘贴 apikey/token/口令"), + _field("ns_label", "标签(可选)", "text", "如 我的GitHub"), + _field("ns_type", "类型(可选)", "text", "如 api_key/password/token"), + _field("ns_remark", "备注(可选)", "area", "用途说明"), + {"widgettype": "Button", + "options": {"label": "保存", "css": "primary"}, + "script": _save_js}, + ]}, ensure_ascii=False) diff --git a/wwwroot/secrets/update_user_secret.dspy b/wwwroot/secrets/update_user_secret.dspy new file mode 100644 index 0000000..dfb9bb4 --- /dev/null +++ b/wwwroot/secrets/update_user_secret.dspy @@ -0,0 +1,46 @@ +# update_user_secret.dspy - 行内编辑回传(Tabular update 整行 POST) +# 语义:status 与库中不同 → 启停操作;label/remark 不同 → 元数据更新。仅本人条目。 +import json +uid = await get_user() +if not uid: + return json.dumps({"success": False, "error": "未登录"}, ensure_ascii=False) +p = params_kw or {} +name = str(p.get('name', '') or '').strip() +sid = str(p.get('id', '') or '').strip() +if not name and not sid: + return json.dumps({"success": False, "error": "缺少名称或 id"}, ensure_ascii=False) +org = (await get_userorgid()) or '' +from pipeline_service import secret_vault as sv +msgs = [] +async with DBPools().sqlorContext('pipeline') as sor: + row = await sv.get_secret_row(sor, name=name or None, secret_id=sid or None, + org_id=org, user_id=uid, own_only=True) + if not row: + return json.dumps({"success": False, "error": "找不到该凭据(或不属于你本人)"}, + ensure_ascii=False) + cur_name = row.get("name", "") + cur_status = str(row.get("status", "") or "active") + new_status = str(p.get('status', '') or '').strip() + if new_status and new_status != cur_status: + if new_status not in ('active', 'disabled'): + return json.dumps({"success": False, "error": "status 须为 active/disabled"}, + ensure_ascii=False) + r = await sv.set_secret_status(sor, name=cur_name, status=new_status, + org_id=org, user_id=uid, who=uid) + if not r.get("ok"): + return json.dumps({"success": False, "error": r.get("message", "")}, + ensure_ascii=False) + msgs.append(r.get("message", "")) + new_label = str(p.get('label', '') or '') + new_remark = str(p.get('remark', '') or '') + if new_label != str(row.get('label', '') or '') or new_remark != str(row.get('remark', '') or ''): + r = await sv.update_secret_meta(sor, name=cur_name, label=new_label, + remark=new_remark, org_id=org, user_id=uid, who=uid) + if not r.get("ok"): + return json.dumps({"success": False, "error": r.get("message", "")}, + ensure_ascii=False) + msgs.append(r.get("message", "")) + await sor.sqlExe("COMMIT", {}) +if msgs: + return json.dumps({"success": True, "message": ";".join(msgs)}, ensure_ascii=False) +return json.dumps({"success": True, "message": "无变更"}, ensure_ascii=False)