|
|
@@ -5,6 +5,7 @@ import type { JsonValue } from '@shared/types'
|
|
|
import type { CredentialSummary, CredentialVaultStatus, CredentialOperation } from '@shared/credentialVault'
|
|
|
|
|
|
interface View extends CredentialVaultStatus {
|
|
|
+ bots?:{id:string;name:string}[]
|
|
|
tasks: { workId: string; agentId: string; name: string }[]
|
|
|
requests: { workId: string; agentId: string; credentialRef: string; operation: CredentialOperation }[]
|
|
|
}
|
|
|
@@ -13,6 +14,7 @@ const master=ref(''), confirmation=ref(''), replacement=ref(''), secret=ref(''),
|
|
|
const name=ref(''), username=ref(''), kind=ref<'website'|'ssh'>('website'), origin=ref(''), host=ref(''), port=ref(22), hostKey=ref('')
|
|
|
const editing=ref<CredentialSummary>(), showEditor=ref(false), taskId=ref(''), credentialId=ref(''), operation=ref<CredentialOperation>('website.login')
|
|
|
const sshAuth=ref<'privateKey'|'password'>('password')
|
|
|
+const botId=ref('')
|
|
|
const authenticationChanged=computed(()=>editing.value?.target.kind==='ssh'&&kind.value==='ssh'&&sshAuth.value!==(editing.value.target.auth??'privateKey'))
|
|
|
const secretRequired=computed(()=>!editing.value||authenticationChanged.value)
|
|
|
const selectedCredential=computed(()=>state.value?.entries.find(entry=>entry.id===credentialId.value))
|
|
|
@@ -39,8 +41,8 @@ async function action(work:()=>Promise<void>,message:string){
|
|
|
finally{clearSecrets();busy.value=false}
|
|
|
}
|
|
|
async function create(){if(master.value!==confirmation.value){error.value='两次主密码不一致';return}await action(async()=>{await request('/initialize','POST',{password:master.value})},'密码库已创建,仍保持锁定')}
|
|
|
-async function unlock(){await action(async()=>{await request('/unlock','POST',{password:master.value,seconds:300})},'已手动解锁 5 分钟;Bot 使用凭据仍需要逐任务授权。')}
|
|
|
-async function lock(){clearSecrets();showEditor.value=false;await action(async()=>{await request('/lock','POST',{})},'密码库已锁定,任务授权已撤销')}
|
|
|
+async function unlock(){await action(async()=>{await request('/unlock','POST',{password:master.value,seconds:300})},state.value?.storageMode==='local'?'已解锁管理 5 分钟;已授权 Bot 的使用不受此期限影响。':'已手动解锁 5 分钟;Bot 使用凭据仍需要逐任务授权。')}
|
|
|
+async function lock(){clearSecrets();showEditor.value=false;await action(async()=>{await request('/lock','POST',{})},state.value?.storageMode==='local'?'管理界面已锁定;已授权 Bot 仍可使用。':'密码库已锁定,任务授权已撤销')}
|
|
|
function edit(entry?:CredentialSummary){
|
|
|
clearSecrets();editing.value=entry;name.value=entry?.name??'';username.value=entry?.username??'';kind.value=entry?.target.kind??'website'
|
|
|
origin.value=entry?.target.kind==='website'?entry.target.origin:'';host.value=entry?.target.kind==='ssh'?entry.target.host:''
|
|
|
@@ -59,13 +61,14 @@ async function save(){
|
|
|
const target:Record<string,JsonValue>=kind.value==='website'?{kind:'website',origin:origin.value}:{kind:'ssh',host:host.value,port:port.value,...(hostKey.value.trim()?{hostKey:hostKey.value.trim()}:{}),...(sshAuth.value==='password'?{auth:'password'}:{})}
|
|
|
const usage:Record<string,JsonValue>|undefined=!configureUse.value?undefined:kind.value==='website'?{kind:'website.form',loginPath:loginPath.value,submitPath:submitPath.value,successPath:successPath.value,...(logoutPath.value?{logoutPath:logoutPath.value}:{}),formId:formId.value,usernameName:usernameName.value,passwordName:passwordName.value,successSelector:successSelector.value}:useKind.value==='ssh.exec'?{kind:'ssh.exec',command:command.value}:useKind.value==='sftp.read'?{kind:'sftp.read',remotePath:remotePath.value,maxBytes:maxBytes.value}:{kind:'sftp.write',remotePath:remotePath.value,...(contents.value||!editing.value||editing.value.usage?.kind!=='sftp.write'?{contents:contents.value}:{})}
|
|
|
const body={name:name.value,username:username.value,target,...(usage?{usage}:{}),...(secret.value?{secret:secret.value}:{}),...(kind.value==='ssh'&&sshAuth.value==='privateKey'?(clearPassphrase.value?{passphrase:''}:passphrase.value?{passphrase:passphrase.value}:{}):{}),...(editing.value?{revision:editing.value.revision}:{})}
|
|
|
- await action(async()=>{await request(editing.value?'/entries/'+editing.value.id:'/entries',editing.value?'PUT':'POST',body);showEditor.value=false},'凭据已加密保存,密码与私钥不回显')
|
|
|
+ await action(async()=>{await request(editing.value?'/entries/'+editing.value.id:'/entries',editing.value?'PUT':'POST',body);showEditor.value=false},editing.value&&state.value?.storageMode==='local'?'凭据已保存;原 Bot 授权已撤销,请重新授权。':'凭据已加密保存,密码与私钥不回显')
|
|
|
}
|
|
|
async function remove(entry:CredentialSummary){await action(async()=>{await request('/entries/'+entry.id,'DELETE')},'凭据已删除,相关任务授权已撤销')}
|
|
|
async function grant(){
|
|
|
const task=state.value?.tasks.find(t=>t.workId===taskId.value);if(!task)return
|
|
|
await action(async()=>{await request('/leases','POST',{credentialRef:credentialId.value,agentId:task.agentId,workId:task.workId,operation:operation.value,seconds:60})},'已授予此任务 60 秒;只执行已配置的操作,密码与执行正文不回显。')
|
|
|
}
|
|
|
+async function grantBot(){await action(async()=>{await request('/bot-grant','POST',{credentialRef:credentialId.value,agentId:botId.value})},'此 Bot 已获持续授权;返回聊天即可使用,直到你撤销或修改凭据。')}
|
|
|
function selectCredential(){const plan=selectedCredential.value?.usage;if(plan)operation.value=plan.kind==='website.form'?'website.login':plan.kind}
|
|
|
function selectRequest(value:View['requests'][number]){taskId.value=value.workId;credentialId.value=value.credentialRef;operation.value=value.operation}
|
|
|
function describeUse(entry?:CredentialSummary){const p=entry?.usage;if(!p)return '尚未配置,将转人工接管';if(p.kind==='website.form')return '网站表单 '+p.loginPath+' → '+p.submitPath+' → '+p.successPath+(p.logoutPath?' → 退出 '+p.logoutPath:'(远端会话按站点规则到期)');if(p.kind==='ssh.exec')return 'SSH '+p.command+',固定无参数';return p.kind==='sftp.read'?'SFTP 读取校验 '+p.remotePath+',最多 '+p.maxBytes+' 字节':'SFTP 创建新文件 '+p.remotePath+','+p.bytes+' 字节'}
|
|
|
@@ -102,8 +105,8 @@ onBeforeUnmount(()=>{
|
|
|
|
|
|
<template>
|
|
|
<section class="credential-vault" :aria-busy="busy">
|
|
|
- <p>秘密只在专用界面录入,由主密码加密保存;不发送给 Bot。密码保险箱默认锁定,重启或到期需重新手动解锁。</p>
|
|
|
- <p v-if="state?.storageMode==='local'" role="status">本机保险箱:凭据加密保存在夭夭服务器,当前账号解锁后可以管理。Bot 可申请使用已配置的固定操作,需要你授权具体任务;操作在夭夭服务器执行,密码不交给模型。</p>
|
|
|
+ <p>秘密只在专用界面录入并加密保存,不发送给 Bot。主密码用于添加、修改凭据和管理授权。</p>
|
|
|
+ <p v-if="state?.storageMode==='local'" role="status">选择凭据和 Bot,授权一次即可使用,直到你撤销。关闭管理页、到期或重启服务不会撤销 Bot 授权;操作在夭夭服务器执行,密码不交给模型。</p>
|
|
|
<p v-else role="status">{{state?.execution==='protected-adapters'?'受控执行器已取得人工部署批准:仅支持配置的标准表单、固定 SSH 命令和指定 SFTP 文件;仍须逐任务授权。':'受控网站与 SSH/SFTP 执行尚未启用。隔离部署批准前,由你人工接管。'}}</p>
|
|
|
<p v-if="error" role="alert">{{error}}</p><p v-if="notice" role="status">{{notice}}</p>
|
|
|
<button type="button" :disabled="busy" @click="action(load,'状态已刷新')">刷新状态</button>
|
|
|
@@ -122,7 +125,7 @@ onBeforeUnmount(()=>{
|
|
|
<p>先输入主密码解锁,即可添加和编辑凭据。</p><button :disabled="busy">手动解锁 5 分钟</button>
|
|
|
</form>
|
|
|
<template v-else>
|
|
|
- <div class="actions"><button type="button" :disabled="busy" @click="lock">立即锁定并撤销授权</button><button type="button" :disabled="busy" @click="edit()">添加凭据</button><button type="button" :disabled="busy" @click="backup">导出加密备份</button></div>
|
|
|
+ <div class="actions"><button type="button" :disabled="busy" @click="lock">{{state.storageMode==='local'?'锁定管理界面':'立即锁定并撤销授权'}}</button><button type="button" :disabled="busy" @click="edit()">添加凭据</button><button type="button" :disabled="busy" @click="backup">导出加密备份</button></div>
|
|
|
<ul><li v-for="entry in state.entries" :key="entry.id"><strong>{{entry.name}}</strong><p>{{entry.username}} · {{entry.target.kind==='website'?entry.target.origin:entry.target.host+':'+entry.target.port}}<span v-if="entry.target.kind==='ssh'"> · {{entry.target.auth==='password'?'密码登录':'私钥登录'}}</span></p><code>{{entry.id}}</code><div class="actions"><button type="button" :disabled="busy" @click="edit(entry)">编辑</button><button type="button" :disabled="busy" @click="remove(entry)">删除并撤权</button></div></li></ul>
|
|
|
<form v-if="showEditor" @submit.prevent="save">
|
|
|
<label>名称<input v-model="name" maxlength="120" required :disabled="busy" /></label>
|
|
|
@@ -156,7 +159,16 @@ onBeforeUnmount(()=>{
|
|
|
</template>
|
|
|
<div class="actions"><button :disabled="busy">加密保存</button><button type="button" :disabled="busy" @click="showEditor=false;clearSecrets()">取消</button></div>
|
|
|
</form>
|
|
|
- <form @submit.prevent="grant">
|
|
|
+ <form v-if="state.storageMode==='local'" @submit.prevent="grantBot">
|
|
|
+ <h4>授权 Bot 使用</h4><p>授权一次,之后直接使用;管理界面锁定不影响授权。修改凭据会撤销原授权。</p>
|
|
|
+ <label>凭据<select v-model="credentialId" required :disabled="busy" @change="selectCredential"><option disabled value="">请选择</option><option v-for="e in state.entries" :key="e.id" :value="e.id">{{e.name}}</option></select></label>
|
|
|
+ <label>Bot<select v-model="botId" required :disabled="busy"><option disabled value="">请选择</option><option v-for="bot in state.bots??[]" :key="bot.id" :value="bot.id">{{bot.name}}</option></select></label>
|
|
|
+ <p>允许操作:{{describeUse(selectedCredential)}}</p>
|
|
|
+ <p v-if="firstSshUse">首次授权会自动记住服务器指纹。</p>
|
|
|
+ <button :disabled="busy||!botId||!selectedCredential?.usage">授权此 Bot 使用</button>
|
|
|
+ <ul><li v-for="g in state.botGrants??[]" :key="g.credentialRef+g.agentId">{{g.name}} → {{state.bots?.find(b=>b.id===g.agentId)?.name??'Bot'}}<button type="button" :disabled="busy" @click="action(async()=>{await request('/bot-revoke','POST',{credentialRef:g.credentialRef,agentId:g.agentId})},'Bot 授权已撤销')">撤销 Bot 授权</button></li></ul>
|
|
|
+ </form>
|
|
|
+ <form v-else @submit.prevent="grant">
|
|
|
<h4>本次任务授权</h4><p>先为凭据配置允许 Bot 执行的固定操作,再授权活动任务。每次只绑定一个 Bot、当前任务、执行节点和指定操作;最长 60 秒,不建立长期访问权限。</p>
|
|
|
<ul><li v-for="pending in state.requests" :key="pending.workId+pending.credentialRef">
|
|
|
{{state.tasks.find(t=>t.workId===pending.workId)?.name??'任务'}} · {{pending.operation}} · {{pending.credentialRef}}
|
|
|
@@ -172,7 +184,7 @@ onBeforeUnmount(()=>{
|
|
|
<ul><li v-for="lease in state.leases" :key="lease.id">{{lease.workId.slice(0,8)}} · {{lease.operation}} · {{new Date(lease.expiresAt).toLocaleTimeString()}}<button type="button" :disabled="busy" @click="action(async()=>{await request('/leases/'+lease.id,'DELETE')},'任务授权已撤销')">撤销</button></li></ul>
|
|
|
<form @submit.prevent="rotate"><label>新的主密码<input v-model="replacement" type="password" autocomplete="new-password" minlength="12" maxlength="1024" required :disabled="busy" /></label><button :disabled="busy">轮换主密码与加密密钥并锁定</button></form>
|
|
|
</template>
|
|
|
- <p v-if="state.requests.length">有 {{state.requests.length}} 个任务等待人工解锁或授权;不会自动登录。</p>
|
|
|
+ <p v-if="state.requests.length">有 {{state.requests.length}} 个任务等待{{state.storageMode==='local'?'你授权 Bot':'人工解锁或授权'}}。</p>
|
|
|
</template>
|
|
|
</section>
|
|
|
</template>
|