mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
refactor(approval-grants-ui): paginated list, Element Plus icons, shorter sidebar label
This commit is contained in:
parent
2f5e06f286
commit
65cf53779a
@ -1,6 +1,8 @@
|
||||
package vip.mate.approval.grant.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@ -96,14 +98,16 @@ public class ApprovalGrantController {
|
||||
|
||||
// ─── List ───────────────────────────────────────────────────────────
|
||||
|
||||
@Operation(summary = "列出当前 workspace 的自动批准策略")
|
||||
@Operation(summary = "列出当前 workspace 的自动批准策略(分页)")
|
||||
@GetMapping("/grants")
|
||||
@RequireWorkspaceRole("member")
|
||||
public R<List<ApprovalGrant>> list(
|
||||
public R<IPage<ApprovalGrant>> list(
|
||||
@RequestParam(required = false) String scopeType,
|
||||
@RequestParam(required = false) String toolName,
|
||||
@RequestParam(required = false) Integer revoked,
|
||||
@RequestParam(required = false, defaultValue = "false") boolean mine,
|
||||
@RequestParam(required = false, defaultValue = "1") long page,
|
||||
@RequestParam(required = false, defaultValue = "20") long size,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
||||
Authentication auth) {
|
||||
Long actorId = resolveUserId(auth);
|
||||
@ -114,6 +118,10 @@ public class ApprovalGrantController {
|
||||
workspaceService.requirePermission(ws, actorId, "admin");
|
||||
}
|
||||
|
||||
// Bound page size so a malformed client can't blow up the UI / mapper.
|
||||
long boundedSize = Math.min(Math.max(size, 1), 200);
|
||||
long boundedPage = Math.max(page, 1);
|
||||
|
||||
var wrapper = Wrappers.<ApprovalGrant>lambdaQuery()
|
||||
.eq(ApprovalGrant::getWorkspaceId, ws)
|
||||
.eq(ApprovalGrant::getDeleted, 0)
|
||||
@ -130,7 +138,8 @@ public class ApprovalGrantController {
|
||||
if (mine) {
|
||||
wrapper.eq(ApprovalGrant::getGrantedBy, actorId);
|
||||
}
|
||||
return R.ok(grantMapper.selectList(wrapper));
|
||||
Page<ApprovalGrant> pageObj = new Page<>(boundedPage, boundedSize);
|
||||
return R.ok(grantMapper.selectPage(pageObj, wrapper));
|
||||
}
|
||||
|
||||
// ─── Active summary (chip "(N)") ────────────────────────────────────
|
||||
|
||||
@ -202,9 +202,11 @@ class ApprovalGrantControllerTest {
|
||||
|
||||
@Test
|
||||
void list_mine_does_not_require_admin() {
|
||||
when(grantMapper.selectList(any())).thenReturn(List.of());
|
||||
// selectPage returns a Page object; the test only cares about the auth
|
||||
// path, so the mapper stub just needs to not NPE.
|
||||
when(grantMapper.selectPage(any(), any())).thenReturn(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>());
|
||||
|
||||
controller.list(null, null, null, /*mine*/ true, WORKSPACE_ID, memberAuth);
|
||||
controller.list(null, null, null, /*mine*/ true, 1L, 20L, WORKSPACE_ID, memberAuth);
|
||||
|
||||
verify(workspaceService, never()).requirePermission(anyLong(), anyLong(), anyString());
|
||||
}
|
||||
@ -215,7 +217,7 @@ class ApprovalGrantControllerTest {
|
||||
.when(workspaceService).requirePermission(WORKSPACE_ID, MEMBER_ID, "admin");
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
controller.list(null, null, null, /*mine*/ false, WORKSPACE_ID, memberAuth))
|
||||
controller.list(null, null, null, /*mine*/ false, 1L, 20L, WORKSPACE_ID, memberAuth))
|
||||
.isInstanceOf(MateClawException.class);
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ import axios from 'axios'
|
||||
import { handleAuthFailure, updateTokenFromHeader } from '@/utils/auth'
|
||||
import type {
|
||||
ApprovalGrant,
|
||||
ApprovalGrantPage,
|
||||
ActiveGrantsSummary,
|
||||
CreateGrantPayload,
|
||||
ResolutionLog,
|
||||
@ -1291,13 +1292,18 @@ export const goalApi = {
|
||||
* 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. */
|
||||
/**
|
||||
* List grants visible in the current workspace, paged. mine=true skips the
|
||||
* admin gate. Page is 1-based; size is bounded server-side to [1, 200].
|
||||
*/
|
||||
listGrants: (params?: {
|
||||
scopeType?: GrantScope
|
||||
toolName?: string
|
||||
revoked?: 0 | 1
|
||||
mine?: boolean
|
||||
}) => http.get<ApprovalGrant[]>('/approval/grants', { params }),
|
||||
page?: number
|
||||
size?: number
|
||||
}) => http.get<ApprovalGrantPage>('/approval/grants', { params }),
|
||||
|
||||
/** Active-grant summary used by the global chip + ChatInput pill counters. */
|
||||
activeSummary: () =>
|
||||
|
||||
@ -3732,6 +3732,7 @@ export default {
|
||||
manage: 'Manage auto-approve rules...',
|
||||
},
|
||||
chipLabel: 'Auto-approve active ({count})',
|
||||
menu: 'Auto-approve',
|
||||
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: {
|
||||
|
||||
@ -3824,6 +3824,7 @@ export default {
|
||||
manage: '管理自动批准策略...',
|
||||
},
|
||||
chipLabel: '自动批准已启用 ({count})',
|
||||
menu: '自动批准',
|
||||
title: '自动批准策略',
|
||||
desc: '策略让特定工具调用跳过人审。地板规则(如 rm -rf /、pipe-to-shell)始终生效,CRITICAL 严重度永远人审。',
|
||||
scope: {
|
||||
|
||||
@ -1056,6 +1056,21 @@ export interface ActiveGrantsSummary {
|
||||
hasWorkspaceWide: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Paged response shape from /approval/grants. Mirrors the MyBatis Plus
|
||||
* {@code IPage} JSON layout already used by skills and other paged endpoints in
|
||||
* mateclaw. {@code total/size/current/pages} arrive as JSON strings because the
|
||||
* global Long→String serializer catches them; the consumer coerces via
|
||||
* {@code Number(...)} at the use site so the el-pagination component gets numbers.
|
||||
*/
|
||||
export interface ApprovalGrantPage {
|
||||
records: ApprovalGrant[]
|
||||
total: number | string
|
||||
size: number | string
|
||||
current: number | string
|
||||
pages: number | string
|
||||
}
|
||||
|
||||
/**
|
||||
* Approval-layer final decision row. workspaceId can be null for HARD_BLOCK
|
||||
* events that fired before workspace resolution.
|
||||
|
||||
@ -6,142 +6,218 @@
|
||||
<p class="section-desc">{{ t('approval.grant.desc') }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn-secondary" @click="openCreateDialog(false)">
|
||||
<el-button :icon="Refresh" plain @click="loadGrants" :loading="loading">
|
||||
{{ t('common.refresh') }}
|
||||
</el-button>
|
||||
<el-button :icon="Plus" type="primary" plain @click="openCreateDialog(false)">
|
||||
{{ t('approval.grant.createBtn') }}
|
||||
</button>
|
||||
<button class="btn-danger" @click="openCreateDialog(true)">
|
||||
🔓 {{ t('approval.grant.createWorkspaceBtn') }}
|
||||
</button>
|
||||
</el-button>
|
||||
<el-button :icon="Unlock" type="danger" plain @click="openCreateDialog(true)">
|
||||
{{ t('approval.grant.createWorkspaceBtn') }}
|
||||
</el-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') }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty-state">
|
||||
{{ t('approval.grant.empty') }}
|
||||
</div>
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="rows"
|
||||
:empty-text="t('approval.grant.empty')"
|
||||
size="small"
|
||||
stripe
|
||||
>
|
||||
<el-table-column :label="t('approval.grant.columns.scope')" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="scopeTagType(row.scopeType)"
|
||||
size="small"
|
||||
effect="light"
|
||||
disable-transitions
|
||||
>
|
||||
{{ t(`approval.grant.scope.${scopeI18nKey(row.scopeType)}`) }}
|
||||
</el-tag>
|
||||
<span class="scope-id">{{ row.scopeId }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
:label="t('approval.grant.columns.tool')"
|
||||
prop="toolName"
|
||||
min-width="140"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<code v-if="row.toolName" class="mono">{{ row.toolName }}</code>
|
||||
<el-tag v-else type="danger" size="small" effect="dark">∗ any</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column :label="t('approval.grant.columns.rule')" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<code v-if="row.ruleId" class="mono">{{ row.ruleId }}</code>
|
||||
<span v-else class="muted">∗</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
:label="t('approval.grant.columns.severity')"
|
||||
prop="maxSeverity"
|
||||
width="110"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="severityTagType(row.maxSeverity)" size="small" disable-transitions>
|
||||
{{ row.maxSeverity }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column :label="t('approval.grant.columns.kind')" width="140">
|
||||
<template #default="{ row }">
|
||||
{{ t(`approval.grant.kind.${kindI18nKey(row.grantKind)}`) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column :label="t('approval.grant.columns.expire')" width="160">
|
||||
<template #default="{ row }">
|
||||
<span class="muted">{{ formatDate(row.expireAt) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
:label="t('approval.grant.columns.grantedBy')"
|
||||
prop="grantedBy"
|
||||
width="120"
|
||||
/>
|
||||
|
||||
<el-table-column :label="t('approval.grant.columns.note')" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span :title="row.note || ''" class="note-cell">{{ row.note }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column :label="t('approval.grant.columns.actions')" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.revoked === 0"
|
||||
:icon="Delete"
|
||||
type="danger"
|
||||
size="small"
|
||||
text
|
||||
@click="confirmRevoke(row)"
|
||||
>
|
||||
{{ t('approval.grant.revokeBtn') }}
|
||||
</el-button>
|
||||
<el-tag v-else type="info" size="small" effect="plain" disable-transitions>
|
||||
{{ t('common.revoked') }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-if="total > 0"
|
||||
class="grants-pagination"
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
background
|
||||
small
|
||||
@size-change="loadGrants"
|
||||
@current-change="loadGrants"
|
||||
/>
|
||||
</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>
|
||||
<el-dialog
|
||||
v-model="dialogOpen"
|
||||
:title="dialogWorkspaceWide ? t('approval.grant.createWorkspaceBtn') : t('approval.grant.createBtn')"
|
||||
width="560px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-alert
|
||||
v-if="dialogWorkspaceWide"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="warning-banner"
|
||||
>
|
||||
{{ t('approval.grant.createWorkspaceWarning') }}
|
||||
</el-alert>
|
||||
|
||||
<div v-if="dialogWorkspaceWide" class="warning-banner">
|
||||
⚠️ {{ t('approval.grant.createWorkspaceWarning') }}
|
||||
</div>
|
||||
<el-form :model="form" label-width="120px" class="grant-form">
|
||||
<el-form-item :label="t('approval.grant.form.scopeType')">
|
||||
<el-select v-model="form.scopeType" :disabled="dialogWorkspaceWide" style="width: 100%">
|
||||
<el-option label="CONVERSATION" value="CONVERSATION" />
|
||||
<el-option label="AGENT" value="AGENT" />
|
||||
<el-option label="USER" value="USER" />
|
||||
<el-option label="WORKSPACE" value="WORKSPACE" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('approval.grant.form.scopeId')">
|
||||
<el-input
|
||||
v-model.trim="form.scopeId"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="\d*"
|
||||
placeholder="snowflake id"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('approval.grant.form.toolName')">
|
||||
<el-input v-model.trim="form.toolName" :disabled="dialogWorkspaceWide" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('approval.grant.form.ruleId')">
|
||||
<el-input v-model.trim="form.ruleId" placeholder="(optional)" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('approval.grant.form.maxSeverity')">
|
||||
<el-select v-model="form.maxSeverity" style="width: 100%">
|
||||
<el-option label="LOW" value="LOW" />
|
||||
<el-option label="MEDIUM" value="MEDIUM" />
|
||||
<el-option label="HIGH" value="HIGH" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('approval.grant.form.grantKind')">
|
||||
<el-select v-model="form.grantKind" style="width: 100%">
|
||||
<el-option :label="t('approval.grant.kind.always')" value="ALWAYS" />
|
||||
<el-option :label="t('approval.grant.kind.until')" value="UNTIL_TIMESTAMP" />
|
||||
<el-option :label="t('approval.grant.kind.conversationEnd')" value="UNTIL_CONVERSATION_END" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="form.grantKind === 'UNTIL_TIMESTAMP'"
|
||||
:label="t('approval.grant.form.expireAt')"
|
||||
>
|
||||
<el-date-picker
|
||||
v-model="form.expireAt"
|
||||
type="datetime"
|
||||
style="width: 100%"
|
||||
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('approval.grant.form.note')">
|
||||
<el-input v-model.trim="form.note" />
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="requiresPassword"
|
||||
:label="t('approval.grant.form.password')"
|
||||
>
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
show-password
|
||||
:prefix-icon="Lock"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<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>
|
||||
<template #footer>
|
||||
<el-button @click="dialogOpen = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="creating" @click="submitCreate">
|
||||
{{ t('common.confirm') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -149,6 +225,13 @@
|
||||
import { ref, computed, onMounted, reactive } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Delete,
|
||||
Lock,
|
||||
Plus,
|
||||
Refresh,
|
||||
Unlock,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { approvalApi } from '@/api'
|
||||
import type {
|
||||
ApprovalGrant,
|
||||
@ -160,7 +243,12 @@ import type {
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const grants = ref<ApprovalGrant[]>([])
|
||||
const rows = ref<ApprovalGrant[]>([])
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const loading = ref(false)
|
||||
|
||||
const dialogOpen = ref(false)
|
||||
const dialogWorkspaceWide = ref(false)
|
||||
const creating = ref(false)
|
||||
@ -194,15 +282,29 @@ function emptyForm(): FormState {
|
||||
}
|
||||
|
||||
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 : []
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await approvalApi.listGrants({
|
||||
page: currentPage.value,
|
||||
size: pageSize.value,
|
||||
})
|
||||
const data = (res as any).data ?? res
|
||||
// Backend serializes Long as string (snowflake precision convention); coerce
|
||||
// numeric page metadata at the boundary so el-pagination gets real numbers.
|
||||
rows.value = Array.isArray(data?.records) ? data.records : []
|
||||
total.value = Number(data?.total ?? 0)
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || 'Failed to load grants')
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDialog(workspaceWide: boolean) {
|
||||
@ -246,6 +348,8 @@ async function submitCreate() {
|
||||
await approvalApi.createGrant(payload)
|
||||
ElMessage.success(t('common.success'))
|
||||
dialogOpen.value = false
|
||||
// Reset to page 1 so the just-created row is visible at the top.
|
||||
currentPage.value = 1
|
||||
await loadGrants()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || 'Failed to create grant')
|
||||
@ -282,6 +386,23 @@ function scopeI18nKey(scope: GrantScope): string {
|
||||
}
|
||||
}
|
||||
|
||||
function scopeTagType(scope: GrantScope): 'primary' | 'success' | 'warning' | 'danger' | 'info' {
|
||||
switch (scope) {
|
||||
case 'CONVERSATION': return 'primary'
|
||||
case 'AGENT': return 'warning'
|
||||
case 'USER': return 'success'
|
||||
case 'WORKSPACE': return 'danger'
|
||||
}
|
||||
}
|
||||
|
||||
function severityTagType(sev: GrantSeverity): 'success' | 'warning' | 'danger' {
|
||||
switch (sev) {
|
||||
case 'LOW': return 'success'
|
||||
case 'MEDIUM': return 'warning'
|
||||
case 'HIGH': return 'danger'
|
||||
}
|
||||
}
|
||||
|
||||
function kindI18nKey(kind: GrantKind): string {
|
||||
switch (kind) {
|
||||
case 'ALWAYS': return 'always'
|
||||
@ -292,7 +413,9 @@ function kindI18nKey(kind: GrantKind): string {
|
||||
|
||||
function formatDate(s: string | null): string {
|
||||
if (!s) return '—'
|
||||
return new Date(s).toLocaleString()
|
||||
const d = new Date(s)
|
||||
if (Number.isNaN(d.getTime())) return s
|
||||
return d.toLocaleString()
|
||||
}
|
||||
|
||||
onMounted(loadGrants)
|
||||
@ -301,75 +424,51 @@ onMounted(loadGrants)
|
||||
<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;
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.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;
|
||||
.scope-id {
|
||||
margin-left: 8px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--mc-text-tertiary, #94a3b8);
|
||||
}
|
||||
.grants-table th { font-weight: 600; color: var(--mc-text-secondary, #64748b); }
|
||||
.row-revoked { opacity: 0.5; }
|
||||
.scope-badge {
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
background: var(--mc-surface-tertiary, #f1f5f9);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.note-cell {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin-right: 6px;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.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;
|
||||
.muted {
|
||||
color: var(--mc-text-tertiary, #94a3b8);
|
||||
font-size: 12px;
|
||||
}
|
||||
.modal-card {
|
||||
background: var(--mc-surface-primary, #fff);
|
||||
border-radius: 8px;
|
||||
width: min(540px, 92vw);
|
||||
max-height: 88vh;
|
||||
display: flex; flex-direction: column;
|
||||
|
||||
.grants-pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.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);
|
||||
|
||||
.warning-banner {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.grant-form {
|
||||
padding-top: 4px;
|
||||
}
|
||||
.form-row input:disabled, .form-row select:disabled { background: var(--mc-surface-tertiary, #f1f5f9); cursor: not-allowed; }
|
||||
</style>
|
||||
|
||||
@ -87,7 +87,7 @@ const sections = computed(() => [
|
||||
{
|
||||
id: 'autoApprove',
|
||||
path: '/security/auto-approve',
|
||||
label: t('approval.grant.title'),
|
||||
label: t('approval.grant.menu'),
|
||||
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>',
|
||||
},
|
||||
])
|
||||
|
||||
Loading…
Reference in New Issue
Block a user