feat(ui): auto-approve banner dropdown, management page, and workspace chip

This commit is contained in:
matevip 2026-05-27 14:08:04 +08:00
parent fe072191ea
commit f15b2dced3
10 changed files with 840 additions and 3 deletions

View File

@ -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 }),
}

View File

@ -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);

View File

@ -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

View File

@ -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

View File

@ -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 ====================

View File

@ -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
}

View File

@ -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

View 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">&times;</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>

View File

@ -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) {

View File

@ -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); }