mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(ui): auto-approve banner dropdown, management page, and workspace chip
This commit is contained in:
parent
fe072191ea
commit
f15b2dced3
@ -1,5 +1,12 @@
|
||||
import axios from 'axios'
|
||||
import { handleAuthFailure, updateTokenFromHeader } from '@/utils/auth'
|
||||
import type {
|
||||
ApprovalGrant,
|
||||
ActiveGrantsSummary,
|
||||
CreateGrantPayload,
|
||||
ResolutionLog,
|
||||
GrantScope,
|
||||
} from '@/types'
|
||||
|
||||
// Axios 实例
|
||||
export const http = axios.create({
|
||||
@ -1275,3 +1282,42 @@ export const goalApi = {
|
||||
addCriterion: (id: string, criterion: string) =>
|
||||
http.post<Goal>(`/goals/${id}/criteria`, { criterion }),
|
||||
}
|
||||
|
||||
// ==================== Approval Auto-Grant ====================
|
||||
|
||||
/**
|
||||
* Client for the /api/v1/approval/* surface. The backend serializes all
|
||||
* snowflake ids as strings (CLAUDE.md precision convention); callers should
|
||||
* keep them as strings end-to-end and never run them through Number().
|
||||
*/
|
||||
export const approvalApi = {
|
||||
/** List grants visible in the current workspace. mine=true skips the admin gate. */
|
||||
listGrants: (params?: {
|
||||
scopeType?: GrantScope
|
||||
toolName?: string
|
||||
revoked?: 0 | 1
|
||||
mine?: boolean
|
||||
}) => http.get<ApprovalGrant[]>('/approval/grants', { params }),
|
||||
|
||||
/** Active-grant summary used by the global chip + ChatInput pill counters. */
|
||||
activeSummary: () =>
|
||||
http.get<ActiveGrantsSummary>('/approval/grants/active'),
|
||||
|
||||
/** Create a grant. Returns the persisted row. */
|
||||
createGrant: (payload: CreateGrantPayload) =>
|
||||
http.post<ApprovalGrant>('/approval/grants', payload),
|
||||
|
||||
/** Soft-revoke a grant. Caller must be the grant owner OR a workspace admin. */
|
||||
revokeGrant: (id: string) =>
|
||||
http.delete<void>(`/approval/grants/${id}`),
|
||||
|
||||
/**
|
||||
* Read approval-layer final decisions. {@code grantId} queries require admin;
|
||||
* {@code conversationId} queries are visible to any workspace member.
|
||||
*/
|
||||
listResolutions: (params: {
|
||||
grantId?: string
|
||||
conversationId?: string
|
||||
limit?: number
|
||||
}) => http.get<ResolutionLog[]>('/approval/resolutions', { params }),
|
||||
}
|
||||
|
||||
@ -84,6 +84,32 @@
|
||||
<el-icon><Select /></el-icon>
|
||||
{{ t('chat.approve') }}
|
||||
</button>
|
||||
<!-- Always-approve dropdown — creates an auto-approve grant of the
|
||||
selected scope before continuing with the regular /approve.
|
||||
Workspace-wide grants are intentionally NOT exposed here; they
|
||||
require the password-protected red button in Security >
|
||||
自动批准策略. -->
|
||||
<div class="approval-bar__always-wrap">
|
||||
<button
|
||||
type="button"
|
||||
class="approval-bar__btn approval-bar__btn--always"
|
||||
@click="alwaysApproveOpen = !alwaysApproveOpen"
|
||||
>
|
||||
{{ t('chat.approveAlways') }}
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</button>
|
||||
<div v-if="alwaysApproveOpen" class="approval-bar__menu">
|
||||
<button type="button" class="approval-bar__menu-item" @click="chooseAlwaysApprove('CONVERSATION')">
|
||||
{{ t('chat.approveAlwaysConversation') }}
|
||||
</button>
|
||||
<button type="button" class="approval-bar__menu-item" @click="chooseAlwaysApprove('AGENT')">
|
||||
{{ t('chat.approveAlwaysAgent') }}
|
||||
</button>
|
||||
<button type="button" class="approval-bar__menu-item" @click="chooseAlwaysApprove('USER')">
|
||||
{{ t('chat.approveAlwaysUser') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -204,7 +230,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { CloseBold, MagicStick, Microphone, Paperclip, Promotion, Select, Timer, WarningFilled } from '@element-plus/icons-vue'
|
||||
import { ArrowDown, CloseBold, MagicStick, Microphone, Paperclip, Promotion, Select, Timer, WarningFilled } from '@element-plus/icons-vue'
|
||||
import { useToolLabel } from '@/composables/useToolLabel'
|
||||
import type { ChatAttachment, PendingApprovalMeta, StreamPhase, QueuedMessage } from '@/types'
|
||||
|
||||
@ -276,6 +302,14 @@ const emit = defineEmits<{
|
||||
'attachment-remove': [storedName: string]
|
||||
approve: [pendingId: string]
|
||||
deny: [pendingId: string]
|
||||
/**
|
||||
* Always-approve dropdown: ChatConsole creates an auto-approve grant for the
|
||||
* matching scope, then forwards the regular /approve command. The scope
|
||||
* vocabulary mirrors mate_approval_grant.scope_type minus WORKSPACE (the
|
||||
* banner deliberately excludes the workspace-wide path; that lives in
|
||||
* Security > 自动批准策略 with password confirmation).
|
||||
*/
|
||||
'approve-always': [payload: { pendingId: string; scope: 'CONVERSATION' | 'AGENT' | 'USER' }]
|
||||
talk: []
|
||||
'toggle-thinking': []
|
||||
}>()
|
||||
@ -290,6 +324,15 @@ const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const isFocused = ref(false)
|
||||
const isComposing = ref(false)
|
||||
|
||||
// Always-approve dropdown — collapsed by default; opens on the chevron click,
|
||||
// closes on outside click or after the user picks a scope.
|
||||
const alwaysApproveOpen = ref(false)
|
||||
function chooseAlwaysApprove(scope: 'CONVERSATION' | 'AGENT' | 'USER') {
|
||||
if (!props.pendingApproval) return
|
||||
emit('approve-always', { pendingId: props.pendingApproval.pendingId, scope })
|
||||
alwaysApproveOpen.value = false
|
||||
}
|
||||
|
||||
// 输入值处理
|
||||
const inputValue = computed({
|
||||
get: () => props.modelValue,
|
||||
@ -757,6 +800,47 @@ defineExpose({
|
||||
background: var(--mc-primary-hover, #C1572B);
|
||||
}
|
||||
|
||||
/* Always-approve dropdown: orange-red border to signal it's a security-reducing
|
||||
action vs the regular approve button (solid primary). The dropdown menu is
|
||||
absolutely positioned above the banner so it never gets clipped. */
|
||||
.approval-bar__always-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.approval-bar__btn--always {
|
||||
background: transparent;
|
||||
color: #b91c1c;
|
||||
border: 1px solid #ef4444;
|
||||
}
|
||||
.approval-bar__btn--always:hover {
|
||||
background: #fef2f2;
|
||||
}
|
||||
.approval-bar__menu {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
right: 0;
|
||||
min-width: 160px;
|
||||
background: var(--mc-surface-primary, #fff);
|
||||
border: 1px solid var(--mc-border-light, #e5e7eb);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
}
|
||||
.approval-bar__menu-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
color: var(--mc-text-primary, #0f172a);
|
||||
cursor: pointer;
|
||||
}
|
||||
.approval-bar__menu-item:hover {
|
||||
background: var(--mc-surface-tertiary, #f1f5f9);
|
||||
}
|
||||
|
||||
.approval-bar__btn--deny {
|
||||
background: var(--mc-bg-sunken, #f1f5f9);
|
||||
color: var(--mc-text-secondary, #64748b);
|
||||
|
||||
@ -286,6 +286,11 @@ export default {
|
||||
approvalRequired: 'Approval Required',
|
||||
approve: 'Approve',
|
||||
deny: 'Deny',
|
||||
approveAlways: 'Always approve',
|
||||
approveAlwaysConversation: 'this conversation',
|
||||
approveAlwaysAgent: 'this agent',
|
||||
approveAlwaysUser: 'all my agents',
|
||||
approveAlwaysCreated: 'Auto-approve rule added: {tool}',
|
||||
approvalHint: 'or type <code>/approve</code> / <code>/deny</code>',
|
||||
approved: '✅ Approved',
|
||||
denied: '⛔ Denied',
|
||||
@ -3717,4 +3722,71 @@ export default {
|
||||
exhaustedDetailTurns: 'Turn budget ({used}/{budget}) exhausted.',
|
||||
exhaustedDetailLlm: 'LLM call budget exhausted.',
|
||||
},
|
||||
approval: {
|
||||
grant: {
|
||||
pill: {
|
||||
inactive: 'Inactive',
|
||||
manage: 'Manage auto-approve rules...',
|
||||
},
|
||||
chipLabel: 'Auto-approve active ({count})',
|
||||
title: 'Auto-approve rules',
|
||||
desc: 'Rules let specific tool calls skip manual approval. Safety-floor patterns (rm -rf /, pipe-to-shell, etc.) always apply, and CRITICAL severity always falls back to human approval.',
|
||||
scope: {
|
||||
conversation: 'Conversation',
|
||||
agent: 'Agent',
|
||||
user: 'User',
|
||||
workspace: 'Workspace',
|
||||
},
|
||||
kind: {
|
||||
always: 'Always',
|
||||
until: 'Until expiry',
|
||||
conversationEnd: 'Until conversation ends',
|
||||
},
|
||||
severityCeiling: 'Severity ceiling',
|
||||
createBtn: 'New rule',
|
||||
createWorkspaceBtn: 'Create workspace-wide rule (danger)',
|
||||
createWorkspaceWarning: 'This rule will auto-approve every tool call from every user in this workspace. Safety-floor patterns still block disaster commands, but every other risk gets bypassed. Confirm with your login password.',
|
||||
revokeBtn: 'Revoke',
|
||||
revokeConfirm: 'Revoking will disable this rule. Continue?',
|
||||
viewResolutions: 'View {count} auto-approve events triggered by this rule →',
|
||||
empty: 'No auto-approve rules configured yet',
|
||||
columns: {
|
||||
scope: 'Scope',
|
||||
tool: 'Tool',
|
||||
rule: 'Rule',
|
||||
severity: 'Severity ceiling',
|
||||
kind: 'Kind',
|
||||
expire: 'Expires',
|
||||
grantedBy: 'Granted by',
|
||||
grantedAt: 'Granted at',
|
||||
note: 'Note',
|
||||
actions: 'Actions',
|
||||
},
|
||||
form: {
|
||||
scopeType: 'Scope type',
|
||||
scopeId: 'Scope id',
|
||||
toolName: 'Tool name (empty = any tool)',
|
||||
ruleId: 'Rule id (empty = any rule)',
|
||||
maxSeverity: 'Max severity',
|
||||
grantKind: 'Grant kind',
|
||||
expireAt: 'Expire at',
|
||||
note: 'Note',
|
||||
password: 'Login password (required for sensitive rules)',
|
||||
},
|
||||
},
|
||||
resolution: {
|
||||
source: {
|
||||
userManual: 'Manual approval',
|
||||
autoGrant: 'Auto-approve',
|
||||
hardBlock: 'Safety floor block',
|
||||
timeout: 'Approval timeout',
|
||||
},
|
||||
},
|
||||
hardBlock: {
|
||||
banner: 'This command was blocked by the safety floor; it cannot be executed under any mode.',
|
||||
},
|
||||
forceHuman: {
|
||||
banner: 'This command requires manual approval; auto-approve rules do not apply.',
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
@ -286,6 +286,11 @@ export default {
|
||||
approvalRequired: '需要审批',
|
||||
approve: '批准',
|
||||
deny: '拒绝',
|
||||
approveAlways: '始终批准',
|
||||
approveAlwaysConversation: '此次会话',
|
||||
approveAlwaysAgent: '此 agent',
|
||||
approveAlwaysUser: '我所有 agent',
|
||||
approveAlwaysCreated: '已添加自动批准规则:{tool}',
|
||||
approvalHint: '或输入 <code>/approve</code> / <code>/deny</code>',
|
||||
approved: '✅ 已允许',
|
||||
denied: '⛔ 已拒绝',
|
||||
@ -3809,4 +3814,71 @@ export default {
|
||||
exhaustedDetailTurns: '预算轮数({used}/{budget})用完。',
|
||||
exhaustedDetailLlm: 'LLM 调用预算用完。',
|
||||
},
|
||||
approval: {
|
||||
grant: {
|
||||
pill: {
|
||||
inactive: '未启用',
|
||||
manage: '管理自动批准策略...',
|
||||
},
|
||||
chipLabel: '自动批准已启用 ({count})',
|
||||
title: '自动批准策略',
|
||||
desc: '策略让特定工具调用跳过人审。地板规则(如 rm -rf /、pipe-to-shell)始终生效,CRITICAL 严重度永远人审。',
|
||||
scope: {
|
||||
conversation: '会话',
|
||||
agent: '智能体',
|
||||
user: '用户',
|
||||
workspace: '工作区',
|
||||
},
|
||||
kind: {
|
||||
always: '永久',
|
||||
until: '到期失效',
|
||||
conversationEnd: '会话结束失效',
|
||||
},
|
||||
severityCeiling: '严重度上限',
|
||||
createBtn: '新增策略',
|
||||
createWorkspaceBtn: '创建全工具白名单 (危险)',
|
||||
createWorkspaceWarning: '该策略将允许此工作区内所有用户的所有工具调用自动通过审批。地板规则仍会阻断灾难性命令,但其他风险都将被绕过。请输入登录密码确认。',
|
||||
revokeBtn: '撤销',
|
||||
revokeConfirm: '撤销后此策略将不再生效。继续?',
|
||||
viewResolutions: '查看本规则触发的 {count} 次自动通过 →',
|
||||
empty: '尚未配置自动批准策略',
|
||||
columns: {
|
||||
scope: '范围',
|
||||
tool: '工具',
|
||||
rule: '规则',
|
||||
severity: '严重度上限',
|
||||
kind: '类型',
|
||||
expire: '过期时间',
|
||||
grantedBy: '创建者',
|
||||
grantedAt: '创建时间',
|
||||
note: '备注',
|
||||
actions: '操作',
|
||||
},
|
||||
form: {
|
||||
scopeType: '范围类型',
|
||||
scopeId: '范围 ID',
|
||||
toolName: '工具名(空 = 任意工具)',
|
||||
ruleId: '规则 ID(空 = 任意规则)',
|
||||
maxSeverity: '严重度上限',
|
||||
grantKind: '生效类型',
|
||||
expireAt: '过期时间',
|
||||
note: '备注',
|
||||
password: '登录密码(敏感策略需要)',
|
||||
},
|
||||
},
|
||||
resolution: {
|
||||
source: {
|
||||
userManual: '人工审批',
|
||||
autoGrant: '自动批准',
|
||||
hardBlock: '安全地板阻断',
|
||||
timeout: '审批超时',
|
||||
},
|
||||
},
|
||||
hardBlock: {
|
||||
banner: '此命令命中安全地板,任何模式下都不可执行。',
|
||||
},
|
||||
forceHuman: {
|
||||
banner: '此命令需要人工审批,自动批准策略对其不生效。',
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
@ -265,6 +265,12 @@ const router = createRouter({
|
||||
component: () => import('@/views/Security/AuditLogs/index.vue'),
|
||||
meta: { title: 'Security - Audit Logs', requiredCapability: 'manage:security' },
|
||||
},
|
||||
{
|
||||
path: 'auto-approve',
|
||||
name: 'SecurityAutoApprove',
|
||||
component: () => import('@/views/Security/AutoApproveGrants/index.vue'),
|
||||
meta: { title: 'Security - Auto Approve', requiredCapability: 'manage:security' },
|
||||
},
|
||||
],
|
||||
},
|
||||
// ==================== Forbidden ====================
|
||||
|
||||
@ -1019,3 +1019,75 @@ export interface CronJob {
|
||||
lastDeliveryStatus?: 'NONE' | 'PENDING' | 'DELIVERED' | 'NOT_DELIVERED'
|
||||
lastDeliveryError?: string | null
|
||||
}
|
||||
|
||||
// ==================== Approval Auto-Grant ====================
|
||||
|
||||
export type GrantScope = 'USER' | 'AGENT' | 'CONVERSATION' | 'WORKSPACE'
|
||||
export type GrantKind = 'ALWAYS' | 'UNTIL_TIMESTAMP' | 'UNTIL_CONVERSATION_END'
|
||||
export type GrantSeverity = 'LOW' | 'MEDIUM' | 'HIGH'
|
||||
export type ResolutionDecisionSource = 'USER_MANUAL' | 'AUTO_GRANT' | 'HARD_BLOCK' | 'TIMEOUT'
|
||||
|
||||
/**
|
||||
* A user-authorized rule that lets ApprovalGrantResolver skip the manual
|
||||
* approval step for matching tool calls. All snowflake-typed fields are
|
||||
* strings end-to-end per CLAUDE.md precision convention.
|
||||
*/
|
||||
export interface ApprovalGrant {
|
||||
id: string
|
||||
workspaceId: string
|
||||
scopeType: GrantScope
|
||||
scopeId: string
|
||||
toolName: string | null
|
||||
ruleId: string | null
|
||||
maxSeverity: GrantSeverity
|
||||
grantKind: GrantKind
|
||||
expireAt: string | null
|
||||
grantedBy: string
|
||||
grantedAt: string
|
||||
revoked: number
|
||||
revokedBy: string | null
|
||||
revokedAt: string | null
|
||||
note: string | null
|
||||
}
|
||||
|
||||
/** Active-grant summary for the global chip + ChatInput pill counters. */
|
||||
export interface ActiveGrantsSummary {
|
||||
count: number
|
||||
hasWorkspaceWide: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Approval-layer final decision row. workspaceId can be null for HARD_BLOCK
|
||||
* events that fired before workspace resolution.
|
||||
*/
|
||||
export interface ResolutionLog {
|
||||
id: string
|
||||
workspaceId: string | null
|
||||
conversationId: string | null
|
||||
agentId: string | null
|
||||
userId: string | null
|
||||
toolCallId: string | null
|
||||
toolName: string
|
||||
maxSeverity: GrantSeverity | null
|
||||
ruleIds: string | null
|
||||
decisionSource: ResolutionDecisionSource
|
||||
grantId: string | null
|
||||
pendingId: string | null
|
||||
argsPreview: string | null
|
||||
note: string | null
|
||||
createTime: string
|
||||
}
|
||||
|
||||
/** Payload for POST /approval/grants. */
|
||||
export interface CreateGrantPayload {
|
||||
scopeType: GrantScope
|
||||
scopeId: string
|
||||
toolName?: string | null
|
||||
ruleId?: string | null
|
||||
maxSeverity: GrantSeverity
|
||||
grantKind: GrantKind
|
||||
expireAt?: string | null
|
||||
note?: string | null
|
||||
/** Required when scope+toolName combination is admin+password (see §2.4.5). */
|
||||
password?: string
|
||||
}
|
||||
|
||||
@ -116,6 +116,7 @@
|
||||
@suggestion-click="sendSuggestion"
|
||||
@toggle-thinking="handleToggleThinking"
|
||||
@approve="handleApprove"
|
||||
@approve-always="handleApproveAlways"
|
||||
@deny="handleDeny"
|
||||
>
|
||||
<!-- Issue #81 v2 R2: blocking-only popup. Recoverable cases use the
|
||||
@ -231,6 +232,7 @@
|
||||
@file-select="handleFileSelect"
|
||||
@attachment-remove="removeAttachment"
|
||||
@approve="handleApprove"
|
||||
@approve-always="handleApproveAlways"
|
||||
@deny="handleDeny"
|
||||
:enable-talk-mode="!!selectedAgentId"
|
||||
:thinking-enabled="thinkingEnabled"
|
||||
@ -259,7 +261,8 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { mcToast } from '@/composables/useMcToast'
|
||||
import { ChatDotRound, Delete, Setting, UploadFilled } from '@element-plus/icons-vue'
|
||||
import { conversationApi, agentApi, modelApi, chatApi, cronJobApi } from '@/api/index'
|
||||
import { conversationApi, agentApi, modelApi, chatApi, cronJobApi, approvalApi } from '@/api/index'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
import { useFileDrop } from '@/composables/useFileDrop'
|
||||
import { useIsMobile, useMediaQuery, BREAKPOINTS } from '@/composables/useBreakpoint'
|
||||
@ -1819,6 +1822,57 @@ async function handleDeny(pendingId: string) {
|
||||
await handleSendMessage('/deny')
|
||||
}
|
||||
|
||||
// Always-approve: create the matching grant first, then send /approve as usual.
|
||||
// Failure to create the grant doesn't block the approval — we still forward
|
||||
// /approve so the user's click isn't lost, just toast the error.
|
||||
async function handleApproveAlways(
|
||||
payload: { pendingId: string; scope: 'CONVERSATION' | 'AGENT' | 'USER' },
|
||||
) {
|
||||
if (!currentConversationId.value) return
|
||||
const pa = activePendingApproval.value
|
||||
if (!pa) return
|
||||
|
||||
// Resolve scope_id from the scope dimension.
|
||||
let scopeId = ''
|
||||
if (payload.scope === 'CONVERSATION') {
|
||||
scopeId = currentConversationId.value
|
||||
} else if (payload.scope === 'AGENT') {
|
||||
scopeId = String(currentAgent.value?.id ?? '')
|
||||
} else if (payload.scope === 'USER') {
|
||||
const me = localStorage.getItem('mc-user-id')
|
||||
if (me) scopeId = me
|
||||
}
|
||||
if (!scopeId) {
|
||||
ElMessage.error('Cannot resolve scope id for always-approve')
|
||||
await handleSendMessage('/approve')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const sev = pa.maxSeverity ?? 'LOW'
|
||||
// Severity ceiling = at-or-above the current finding's severity. CRITICAL
|
||||
// never enters this path (the backend rejects it), so HIGH covers the rest.
|
||||
const ceiling = sev === 'HIGH' || sev === 'CRITICAL' ? 'HIGH'
|
||||
: sev === 'MEDIUM' ? 'MEDIUM' : 'LOW'
|
||||
const ruleId = pa.findings?.find((f: { ruleId?: string }) => !!f.ruleId)?.ruleId ?? null
|
||||
await approvalApi.createGrant({
|
||||
scopeType: payload.scope,
|
||||
scopeId,
|
||||
toolName: pa.toolName,
|
||||
ruleId,
|
||||
maxSeverity: ceiling,
|
||||
grantKind: payload.scope === 'CONVERSATION' ? 'UNTIL_CONVERSATION_END' : 'ALWAYS',
|
||||
note: `created from approval banner (${pa.toolName})`,
|
||||
})
|
||||
ElMessage.success(
|
||||
t('chat.approveAlwaysCreated', { tool: pa.toolName }) as string,
|
||||
)
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || 'Failed to create auto-approve rule')
|
||||
}
|
||||
await handleSendMessage('/approve')
|
||||
}
|
||||
|
||||
// 重连到运行中的流
|
||||
async function reconnectStream(conversationId: string) {
|
||||
if (isGenerating.value) return
|
||||
|
||||
375
mateclaw-ui/src/views/Security/AutoApproveGrants/index.vue
Normal file
375
mateclaw-ui/src/views/Security/AutoApproveGrants/index.vue
Normal file
@ -0,0 +1,375 @@
|
||||
<template>
|
||||
<div class="settings-section">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h2 class="section-title">{{ t('approval.grant.title') }}</h2>
|
||||
<p class="section-desc">{{ t('approval.grant.desc') }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn-secondary" @click="openCreateDialog(false)">
|
||||
{{ t('approval.grant.createBtn') }}
|
||||
</button>
|
||||
<button class="btn-danger" @click="openCreateDialog(true)">
|
||||
🔓 {{ t('approval.grant.createWorkspaceBtn') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grants table -->
|
||||
<div class="config-card">
|
||||
<table v-if="grants.length" class="grants-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('approval.grant.columns.scope') }}</th>
|
||||
<th>{{ t('approval.grant.columns.tool') }}</th>
|
||||
<th>{{ t('approval.grant.columns.rule') }}</th>
|
||||
<th>{{ t('approval.grant.columns.severity') }}</th>
|
||||
<th>{{ t('approval.grant.columns.kind') }}</th>
|
||||
<th>{{ t('approval.grant.columns.expire') }}</th>
|
||||
<th>{{ t('approval.grant.columns.grantedBy') }}</th>
|
||||
<th>{{ t('approval.grant.columns.note') }}</th>
|
||||
<th>{{ t('approval.grant.columns.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="g in grants" :key="g.id" :class="{ 'row-revoked': g.revoked === 1 }">
|
||||
<td>
|
||||
<span class="scope-badge" :class="`scope-${g.scopeType.toLowerCase()}`">
|
||||
{{ t(`approval.grant.scope.${scopeI18nKey(g.scopeType)}`) }}
|
||||
</span>
|
||||
<span class="scope-id">{{ g.scopeId }}</span>
|
||||
</td>
|
||||
<td>{{ g.toolName ?? '∗' }}</td>
|
||||
<td>{{ g.ruleId ?? '∗' }}</td>
|
||||
<td>{{ g.maxSeverity }}</td>
|
||||
<td>{{ t(`approval.grant.kind.${kindI18nKey(g.grantKind)}`) }}</td>
|
||||
<td>{{ formatDate(g.expireAt) }}</td>
|
||||
<td>{{ g.grantedBy }}</td>
|
||||
<td class="note-cell" :title="g.note ?? ''">{{ g.note }}</td>
|
||||
<td>
|
||||
<button
|
||||
v-if="g.revoked === 0"
|
||||
class="btn-link"
|
||||
@click="confirmRevoke(g)">
|
||||
{{ t('approval.grant.revokeBtn') }}
|
||||
</button>
|
||||
<span v-else class="muted">{{ t('common.revoked') || 'revoked' }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty-state">
|
||||
{{ t('approval.grant.empty') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create dialog -->
|
||||
<div v-if="dialogOpen" class="modal-backdrop" @click.self="dialogOpen = false">
|
||||
<div class="modal-card">
|
||||
<div class="modal-header">
|
||||
<h3>{{ t('approval.grant.createBtn') }}</h3>
|
||||
<button class="modal-close" @click="dialogOpen = false">×</button>
|
||||
</div>
|
||||
|
||||
<div v-if="dialogWorkspaceWide" class="warning-banner">
|
||||
⚠️ {{ t('approval.grant.createWorkspaceWarning') }}
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="form-row">
|
||||
<label>{{ t('approval.grant.form.scopeType') }}</label>
|
||||
<select v-model="form.scopeType" :disabled="dialogWorkspaceWide">
|
||||
<option value="CONVERSATION">{{ t('approval.grant.scope.conversation') }}</option>
|
||||
<option value="AGENT">{{ t('approval.grant.scope.agent') }}</option>
|
||||
<option value="USER">{{ t('approval.grant.scope.user') }}</option>
|
||||
<option value="WORKSPACE">{{ t('approval.grant.scope.workspace') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>{{ t('approval.grant.form.scopeId') }}</label>
|
||||
<input
|
||||
v-model.trim="form.scopeId"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="\d*"
|
||||
placeholder="snowflake id" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>{{ t('approval.grant.form.toolName') }}</label>
|
||||
<input v-model.trim="form.toolName" type="text" :disabled="dialogWorkspaceWide" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>{{ t('approval.grant.form.ruleId') }}</label>
|
||||
<input v-model.trim="form.ruleId" type="text" placeholder="(optional)" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>{{ t('approval.grant.form.maxSeverity') }}</label>
|
||||
<select v-model="form.maxSeverity">
|
||||
<option value="LOW">LOW</option>
|
||||
<option value="MEDIUM">MEDIUM</option>
|
||||
<option value="HIGH">HIGH</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>{{ t('approval.grant.form.grantKind') }}</label>
|
||||
<select v-model="form.grantKind">
|
||||
<option value="ALWAYS">{{ t('approval.grant.kind.always') }}</option>
|
||||
<option value="UNTIL_TIMESTAMP">{{ t('approval.grant.kind.until') }}</option>
|
||||
<option value="UNTIL_CONVERSATION_END">{{ t('approval.grant.kind.conversationEnd') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="form.grantKind === 'UNTIL_TIMESTAMP'" class="form-row">
|
||||
<label>{{ t('approval.grant.form.expireAt') }}</label>
|
||||
<input v-model="form.expireAt" type="datetime-local" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>{{ t('approval.grant.form.note') }}</label>
|
||||
<input v-model.trim="form.note" type="text" />
|
||||
</div>
|
||||
<div v-if="requiresPassword" class="form-row">
|
||||
<label>{{ t('approval.grant.form.password') }}</label>
|
||||
<input v-model="form.password" type="password" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" @click="dialogOpen = false">{{ t('common.cancel') }}</button>
|
||||
<button
|
||||
class="btn-primary"
|
||||
:disabled="creating"
|
||||
@click="submitCreate">
|
||||
{{ creating ? t('common.processing') : t('common.confirm') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, reactive } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { approvalApi } from '@/api'
|
||||
import type {
|
||||
ApprovalGrant,
|
||||
CreateGrantPayload,
|
||||
GrantScope,
|
||||
GrantKind,
|
||||
GrantSeverity,
|
||||
} from '@/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const grants = ref<ApprovalGrant[]>([])
|
||||
const dialogOpen = ref(false)
|
||||
const dialogWorkspaceWide = ref(false)
|
||||
const creating = ref(false)
|
||||
|
||||
interface FormState {
|
||||
scopeType: GrantScope
|
||||
scopeId: string
|
||||
toolName: string
|
||||
ruleId: string
|
||||
maxSeverity: GrantSeverity
|
||||
grantKind: GrantKind
|
||||
expireAt: string
|
||||
note: string
|
||||
password: string
|
||||
}
|
||||
|
||||
const form = reactive<FormState>(emptyForm())
|
||||
|
||||
function emptyForm(): FormState {
|
||||
return {
|
||||
scopeType: 'CONVERSATION',
|
||||
scopeId: '',
|
||||
toolName: '',
|
||||
ruleId: '',
|
||||
maxSeverity: 'LOW',
|
||||
grantKind: 'ALWAYS',
|
||||
expireAt: '',
|
||||
note: '',
|
||||
password: '',
|
||||
}
|
||||
}
|
||||
|
||||
const requiresPassword = computed(() => {
|
||||
// Backend §2.4.5: password is required for (WORKSPACE/AGENT + tool=null).
|
||||
const noTool = !form.toolName
|
||||
return noTool && (form.scopeType === 'WORKSPACE' || form.scopeType === 'AGENT')
|
||||
})
|
||||
|
||||
async function loadGrants() {
|
||||
const res = await approvalApi.listGrants({ mine: false })
|
||||
const payload = (res as any).data ?? res
|
||||
grants.value = Array.isArray(payload) ? payload : []
|
||||
}
|
||||
|
||||
function openCreateDialog(workspaceWide: boolean) {
|
||||
Object.assign(form, emptyForm())
|
||||
if (workspaceWide) {
|
||||
form.scopeType = 'WORKSPACE'
|
||||
form.toolName = ''
|
||||
form.maxSeverity = 'HIGH'
|
||||
dialogWorkspaceWide.value = true
|
||||
} else {
|
||||
dialogWorkspaceWide.value = false
|
||||
}
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
if (!form.scopeId) {
|
||||
ElMessage.warning(t('approval.grant.form.scopeId'))
|
||||
return
|
||||
}
|
||||
creating.value = true
|
||||
try {
|
||||
const payload: CreateGrantPayload = {
|
||||
scopeType: form.scopeType,
|
||||
scopeId: form.scopeId,
|
||||
toolName: form.toolName || null,
|
||||
ruleId: form.ruleId || null,
|
||||
maxSeverity: form.maxSeverity,
|
||||
grantKind: form.grantKind,
|
||||
expireAt: form.expireAt || null,
|
||||
note: form.note || null,
|
||||
}
|
||||
if (requiresPassword.value) {
|
||||
if (!form.password) {
|
||||
ElMessage.warning(t('approval.grant.form.password'))
|
||||
creating.value = false
|
||||
return
|
||||
}
|
||||
payload.password = form.password
|
||||
}
|
||||
await approvalApi.createGrant(payload)
|
||||
ElMessage.success(t('common.success') || 'Created')
|
||||
dialogOpen.value = false
|
||||
await loadGrants()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || 'Failed to create grant')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRevoke(g: ApprovalGrant) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
t('approval.grant.revokeConfirm'),
|
||||
t('approval.grant.revokeBtn'),
|
||||
{ type: 'warning' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await approvalApi.revokeGrant(g.id)
|
||||
ElMessage.success(t('common.success') || 'Revoked')
|
||||
await loadGrants()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || 'Failed to revoke')
|
||||
}
|
||||
}
|
||||
|
||||
function scopeI18nKey(scope: GrantScope): string {
|
||||
switch (scope) {
|
||||
case 'CONVERSATION': return 'conversation'
|
||||
case 'AGENT': return 'agent'
|
||||
case 'USER': return 'user'
|
||||
case 'WORKSPACE': return 'workspace'
|
||||
}
|
||||
}
|
||||
|
||||
function kindI18nKey(kind: GrantKind): string {
|
||||
switch (kind) {
|
||||
case 'ALWAYS': return 'always'
|
||||
case 'UNTIL_TIMESTAMP': return 'until'
|
||||
case 'UNTIL_CONVERSATION_END': return 'conversationEnd'
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(s: string | null): string {
|
||||
if (!s) return '—'
|
||||
return new Date(s).toLocaleString()
|
||||
}
|
||||
|
||||
onMounted(loadGrants)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import '@/views/Security/shared.css';
|
||||
|
||||
.header-actions { display: flex; gap: 8px; align-items: center; }
|
||||
|
||||
.btn-danger {
|
||||
padding: 8px 14px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #ef4444;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-danger:hover { background: #fee2e2; }
|
||||
|
||||
.grants-table { width: 100%; border-collapse: collapse; }
|
||||
.grants-table th,
|
||||
.grants-table td {
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid var(--mc-border-light, #e5e7eb);
|
||||
text-align: left;
|
||||
}
|
||||
.grants-table th { font-weight: 600; color: var(--mc-text-secondary, #64748b); }
|
||||
.row-revoked { opacity: 0.5; }
|
||||
.scope-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin-right: 6px;
|
||||
}
|
||||
.scope-conversation { background: #e0f2fe; color: #075985; }
|
||||
.scope-agent { background: #fef3c7; color: #92400e; }
|
||||
.scope-user { background: #ddd6fe; color: #5b21b6; }
|
||||
.scope-workspace { background: #fee2e2; color: #991b1b; }
|
||||
.scope-id { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 12px; color: var(--mc-text-tertiary, #94a3b8); }
|
||||
.note-cell { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.btn-link { background: none; border: none; color: #ef4444; cursor: pointer; padding: 0; }
|
||||
.empty-state { padding: 32px; text-align: center; color: var(--mc-text-tertiary, #94a3b8); }
|
||||
.muted { color: var(--mc-text-tertiary, #94a3b8); font-size: 12px; }
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal-card {
|
||||
background: var(--mc-surface-primary, #fff);
|
||||
border-radius: 8px;
|
||||
width: min(540px, 92vw);
|
||||
max-height: 88vh;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.modal-header { padding: 16px 20px; border-bottom: 1px solid var(--mc-border-light, #e5e7eb); display: flex; justify-content: space-between; align-items: center; }
|
||||
.modal-header h3 { margin: 0; font-size: 16px; }
|
||||
.modal-close { background: none; border: none; font-size: 20px; cursor: pointer; color: var(--mc-text-tertiary, #94a3b8); }
|
||||
.modal-body { padding: 16px 20px; overflow-y: auto; }
|
||||
.modal-footer { padding: 12px 20px; border-top: 1px solid var(--mc-border-light, #e5e7eb); display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.warning-banner { background: #fef2f2; color: #991b1b; padding: 12px 20px; margin: 0; font-size: 13px; line-height: 1.5; border-bottom: 1px solid #fecaca; }
|
||||
.form-row { display: grid; grid-template-columns: 140px 1fr; gap: 12px; align-items: center; margin-bottom: 12px; }
|
||||
.form-row label { font-size: 13px; color: var(--mc-text-secondary, #64748b); }
|
||||
.form-row input, .form-row select {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--mc-border-light, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
background: var(--mc-surface-primary, #fff);
|
||||
color: var(--mc-text-primary, #0f172a);
|
||||
}
|
||||
.form-row input:disabled, .form-row select:disabled { background: var(--mc-surface-tertiary, #f1f5f9); cursor: not-allowed; }
|
||||
</style>
|
||||
@ -84,6 +84,12 @@ const sections = computed(() => [
|
||||
label: t('security.sections.auditLogs'),
|
||||
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>',
|
||||
},
|
||||
{
|
||||
id: 'autoApprove',
|
||||
path: '/security/auto-approve',
|
||||
label: t('approval.grant.title'),
|
||||
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>',
|
||||
},
|
||||
])
|
||||
|
||||
function isActive(path: string) {
|
||||
|
||||
@ -133,6 +133,18 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Auto-approve chip — red on purpose: security-reducing setting needs
|
||||
persistent visibility, not a friendly green badge. -->
|
||||
<button
|
||||
v-if="autoApproveSummary && autoApproveSummary.count > 0"
|
||||
class="auto-approve-chip"
|
||||
@click="goAutoApproveSettings"
|
||||
:title="t('approval.grant.title')"
|
||||
>
|
||||
🔓
|
||||
<span>{{ t('approval.grant.chipLabel', { count: autoApproveSummary.count }) }}</span>
|
||||
</button>
|
||||
|
||||
<div class="shortcuts-hint" :title="shortcutsHintText">
|
||||
<kbd>Ctrl+K</kbd>
|
||||
<span>{{ t('nav.shortcutAgents') }}</span>
|
||||
@ -197,7 +209,8 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useThemeStore } from '@/stores/useThemeStore'
|
||||
import { version as appVersion } from '../../../package.json'
|
||||
import type { ThemeMode } from '@/stores/useThemeStore'
|
||||
import { http, settingsApi, setupApi } from '@/api/index'
|
||||
import { http, settingsApi, setupApi, approvalApi } from '@/api/index'
|
||||
import type { ActiveGrantsSummary } from '@/types'
|
||||
import OnboardingWizard from '@/views/Onboarding/OnboardingWizard.vue'
|
||||
import DoctorDrawer from '@/views/Doctor/DoctorDrawer.vue'
|
||||
import WorkspaceSwitcher from '@/components/workspace/WorkspaceSwitcher.vue'
|
||||
@ -237,6 +250,22 @@ async function fetchHealthStatus() {
|
||||
}
|
||||
}
|
||||
|
||||
// Active auto-approve grants summary — drives the red "auto-approve active (N)"
|
||||
// chip in the sidebar footer. Red (not green) is intentional: this is a
|
||||
// security-reducing setting and the UI should keep reminding the user it's on.
|
||||
const autoApproveSummary = ref<ActiveGrantsSummary | null>(null)
|
||||
async function fetchAutoApproveSummary() {
|
||||
try {
|
||||
const res: any = await approvalApi.activeSummary()
|
||||
autoApproveSummary.value = res?.data || res
|
||||
} catch {
|
||||
autoApproveSummary.value = null
|
||||
}
|
||||
}
|
||||
function goAutoApproveSettings() {
|
||||
router.push('/security/auto-approve')
|
||||
}
|
||||
|
||||
// Sidebar attention signals — admin-only. Both `/agents` (stuck agents in the
|
||||
// Live view) and `/security` (pending approvals) read from a shared 15s poller
|
||||
// so multiple consumers don't multiply HTTP traffic.
|
||||
@ -323,6 +352,10 @@ onMounted(async () => {
|
||||
|
||||
// Fetch initial health status for sidebar indicator
|
||||
fetchHealthStatus()
|
||||
// Auto-approve chip count. Cheap query (single SELECT COUNT) so we just
|
||||
// fetch on mount and on workspace switch (handled by router-view key change
|
||||
// which re-mounts the route subtree).
|
||||
fetchAutoApproveSummary()
|
||||
// Sidebar attention counts (live / security) are driven by
|
||||
// useNotificationCenter — it polls when admins are mounted.
|
||||
})
|
||||
@ -774,6 +807,23 @@ watch(() => workspaceStore.currentWorkspaceId, () => {
|
||||
}
|
||||
.health-indicator { display: flex; align-items: center; gap: 8px; width: 100%; padding: 8px 10px; border: 1px solid var(--mc-border-light); background: var(--mc-bg-muted); border-radius: 12px; cursor: pointer; color: var(--mc-text-secondary); font-size: 12px; margin-bottom: 8px; }
|
||||
.health-indicator:hover { background: var(--mc-bg-sunken); }
|
||||
|
||||
/* Auto-approve chip — red border + red text, the persistent reminder that
|
||||
this workspace currently has active auto-approve rules. Clicking takes
|
||||
the user to Security > 自动批准策略 so they can review or revoke. */
|
||||
.auto-approve-chip {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
width: 100%; padding: 6px 10px;
|
||||
border: 1px solid #ef4444;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.auto-approve-chip:hover { background: #fee2e2; }
|
||||
.health-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.health-indicator.healthy .health-dot { background: var(--mc-success); }
|
||||
.health-indicator.warning .health-dot { background: var(--mc-primary); }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user