feat(secrets): 我的凭据管理页(2026-09-18用户要求补UI缺口)——secrets/目录:index.ui(Tabular列表元数据+行内启停/改标签备注+删除+审计表)+new_secret_popup.dspy(新增弹窗,值经UiPassword掩码仅POST传输)+get/update/delete/audit四端点;值永不进列表与编辑对话框;权限走/pipeline_core/**通配已覆盖
This commit is contained in:
parent
8487cae5cd
commit
004e65a8f8
16
wwwroot/secrets/delete_user_secret.dspy
Normal file
16
wwwroot/secrets/delete_user_secret.dspy
Normal file
@ -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)
|
||||
23
wwwroot/secrets/get_secret_audit.dspy
Normal file
23
wwwroot/secrets/get_secret_audit.dspy
Normal file
@ -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
|
||||
29
wwwroot/secrets/get_user_secrets.dspy
Normal file
29
wwwroot/secrets/get_user_secrets.dspy
Normal file
@ -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
|
||||
84
wwwroot/secrets/index.ui
Normal file
84
wwwroot/secrets/index.ui
Normal file
@ -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": "操作者"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
85
wwwroot/secrets/new_secret_popup.dspy
Normal file
85
wwwroot/secrets/new_secret_popup.dspy
Normal file
@ -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)
|
||||
46
wwwroot/secrets/update_user_secret.dspy
Normal file
46
wwwroot/secrets/update_user_secret.dspy
Normal file
@ -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)
|
||||
Loading…
x
Reference in New Issue
Block a user