Files
dbx/apps/desktop/src/components/mq/PermissionsPanel.vue
T

834 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { formatError } from "@/lib/errorUtils";
import { computed, nextTick, ref, watch } from "vue";
import type { AuthAction, MqIssuedToken, MqTokenRecord, PermissionMap, PolicyScope, TopicInfo } from "@/types/mq";
import { mqGrantPermission, mqIssueToken, mqListPermissions, mqListTokenRecords, mqRevokePermission } from "@/lib/api";
import { formatMqTokenIssueError, type MqTokenIssueErrorView } from "@/lib/mqTokenErrors";
interface Props {
connectionId: string;
tenant?: string;
namespace?: string;
topic?: TopicInfo;
readOnly?: boolean;
}
const props = defineProps<Props>();
const actionOptions: AuthAction[] = ["produce", "consume", "functions", "sources", "sinks", "packages"];
const permissions = ref<PermissionMap>({});
const loading = ref(false);
const error = ref<string>();
const notice = ref<string>();
const roleName = ref("");
const roleNameInput = ref<HTMLInputElement | null>(null);
const roleNameError = ref("");
const selectedActions = ref<AuthAction[]>(["produce", "consume"]);
const actionsError = ref("");
const tokenRole = ref("");
const tokenActions = ref<AuthAction[]>([]);
const tokenExpiresUnlimited = ref(true);
const tokenExpiresDays = ref(30);
const tokenNote = ref("");
const tokenRecords = ref<MqTokenRecord[]>([]);
const tokenLoading = ref(false);
const tokenError = ref<string>();
const tokenIssueError = ref<MqTokenIssueErrorView>();
const issuedToken = ref<MqIssuedToken>();
const showTokenDialog = ref(false);
const readOnlyMessage = "当前连接为只读模式,不能执行写操作";
const scope = computed<PolicyScope | null>(() => {
if (!props.tenant || !props.namespace) return null;
if (props.topic) {
return {
level: "topic",
tenant: props.tenant,
namespace: props.namespace,
topic: props.topic.shortName,
persistent: props.topic.persistent,
};
}
return {
level: "namespace",
tenant: props.tenant,
namespace: props.namespace,
};
});
const scopeLabel = computed(() => {
const current = scope.value;
if (!current) return "";
if (current.level === "topic") {
return `${current.tenant}/${current.namespace}/${current.topic}`;
}
return `${current.tenant}/${current.namespace}`;
});
const permissionRows = computed(() => {
return Object.entries(permissions.value).map(([role, actions]) => ({ role, actions }));
});
function guardWritable() {
if (props.readOnly) {
error.value = readOnlyMessage;
notice.value = undefined;
return false;
}
return true;
}
async function loadPermissions() {
const current = scope.value;
permissions.value = {};
notice.value = undefined;
error.value = undefined;
if (!current) return;
loading.value = true;
try {
permissions.value = await mqListPermissions(props.connectionId, current);
} catch (e: unknown) {
const msg = formatError(e);
// Gracefully handle Kafka brokers without an authorizer configured.
if (msg.includes("SecurityDisabled") || msg.includes("No Authorizer") || msg.includes("authorizer")) {
error.value = undefined;
notice.value = "当前 Kafka 集群未启用权限管理(Authorizer 未配置)。如需 ACL 功能,请在 Broker 配置中添加 authorizer.class.name。";
} else {
error.value = msg;
}
} finally {
loading.value = false;
}
}
async function grantPermission() {
if (!guardWritable()) return;
const current = scope.value;
const role = roleName.value.trim();
roleNameError.value = "";
actionsError.value = "";
if (!current) {
error.value = "请先选择命名空间或主题";
return;
}
if (!role) {
error.value = undefined;
roleNameError.value = "请输入角色名";
await nextTick();
roleNameInput.value?.focus();
return;
}
if (!selectedActions.value.length) {
error.value = undefined;
actionsError.value = "请至少选择一个权限动作";
return;
}
loading.value = true;
error.value = undefined;
notice.value = undefined;
try {
await mqGrantPermission(props.connectionId, current, role, [...selectedActions.value]);
notice.value = `已授权 ${role}`;
roleName.value = "";
await loadPermissions();
} catch (e: unknown) {
const msg = formatError(e);
if (msg.includes("SecurityDisabled") || msg.includes("No Authorizer") || msg.includes("authorizer")) {
error.value = "当前 Kafka 集群未启用权限管理,无法执行授权操作。请在 Broker 配置中启用 Authorizer。";
} else {
error.value = msg;
}
} finally {
loading.value = false;
}
}
async function revokePermission(role: string) {
if (!guardWritable()) return;
const current = scope.value;
if (!current) {
error.value = "请先选择命名空间或主题";
return;
}
if (!confirm(`确定要撤销角色 "${role}" 的权限吗?`)) return;
loading.value = true;
error.value = undefined;
notice.value = undefined;
try {
await mqRevokePermission(props.connectionId, current, role);
notice.value = `已撤销 ${role}`;
await loadPermissions();
} catch (e: unknown) {
error.value = formatError(e);
} finally {
loading.value = false;
}
}
function openTokenDialog(role: string) {
const row = permissionRows.value.find((item) => item.role === role);
tokenRole.value = role;
tokenActions.value = row?.actions?.length ? [...row.actions] : ["produce", "consume"];
tokenExpiresUnlimited.value = true;
tokenExpiresDays.value = 30;
tokenNote.value = "";
tokenError.value = undefined;
tokenIssueError.value = undefined;
issuedToken.value = undefined;
tokenRecords.value = [];
showTokenDialog.value = true;
void loadTokenRecords();
}
function closeTokenDialog() {
showTokenDialog.value = false;
issuedToken.value = undefined;
}
async function loadTokenRecords() {
if (!tokenRole.value) return;
tokenLoading.value = true;
tokenError.value = undefined;
try {
tokenRecords.value = await mqListTokenRecords(props.connectionId, tokenRole.value);
} catch (e: unknown) {
if (isTokenRecordListUnavailable(e)) {
tokenRecords.value = [];
return;
}
tokenError.value = formatError(e);
} finally {
tokenLoading.value = false;
}
}
async function issueToken() {
if (props.readOnly) {
tokenError.value = readOnlyMessage;
tokenIssueError.value = undefined;
return;
}
const current = scope.value;
if (!current) {
tokenError.value = "请先选择命名空间或主题";
tokenIssueError.value = undefined;
return;
}
const subject = tokenRole.value.trim();
if (!subject) {
tokenError.value = "角色名不能为空";
tokenIssueError.value = undefined;
return;
}
let expiresInSeconds: number | undefined;
if (!tokenExpiresUnlimited.value) {
const days = Number(tokenExpiresDays.value);
if (!Number.isFinite(days) || days <= 0) {
tokenError.value = "有效期必须大于 0 天";
tokenIssueError.value = undefined;
return;
}
expiresInSeconds = Math.round(days * 24 * 60 * 60);
}
tokenLoading.value = true;
tokenError.value = undefined;
tokenIssueError.value = undefined;
issuedToken.value = undefined;
try {
issuedToken.value = await mqIssueToken(props.connectionId, {
subject,
expiresInSeconds,
scope: current,
actions: [...tokenActions.value],
note: tokenNote.value.trim() || undefined,
});
await loadTokenRecords();
} catch (e: unknown) {
const formatted = formatMqTokenIssueError(e);
if (formatted.kind === "missingSigningKey") {
tokenIssueError.value = formatted;
tokenError.value = undefined;
} else {
tokenError.value = formatted.message;
}
} finally {
tokenLoading.value = false;
}
}
async function copyIssuedToken() {
if (!issuedToken.value?.token) return;
await navigator.clipboard?.writeText(issuedToken.value.token);
}
function formatDate(value?: string) {
if (!value) return "长期";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
function shortFingerprint(value: string) {
if (value.length <= 22) return value;
return `${value.slice(0, 14)}...${value.slice(-8)}`;
}
function isTokenRecordListUnavailable(e: unknown) {
const message = e instanceof Error ? formatError(e) : String(e ?? "");
return message.includes("/api/mq/tokens/list returned 404");
}
watch(roleName, (value) => {
if (value.trim()) {
roleNameError.value = "";
}
});
watch(selectedActions, (value) => {
if (value.length) {
actionsError.value = "";
}
});
watch(
() => [props.tenant, props.namespace, props.topic?.name, props.topic?.persistent],
() => {
loadPermissions();
},
{ immediate: true },
);
</script>
<template>
<div class="permissions-panel">
<div class="panel-toolbar">
<div>
<h3>权限管理</h3>
<div v-if="scopeLabel" class="scope-label">{{ scopeLabel }}</div>
</div>
<button @click="loadPermissions" :disabled="loading || !scope" class="btn-sm">
{{ loading ? "刷新中..." : "刷新" }}
</button>
</div>
<div v-if="!scope" class="panel-placeholder">请先选择命名空间或主题</div>
<div v-else class="permissions-content">
<div v-if="readOnly" class="readonly-hint">当前连接为只读模式,授权和撤销已禁用。</div>
<div v-if="error" class="panel-error">{{ error }}</div>
<div v-if="notice" class="panel-notice">{{ notice }}</div>
<section class="grant-panel">
<h4>授权角色</h4>
<div class="grant-row">
<label>
角色名
<input ref="roleNameInput" v-model="roleName" type="text" placeholder="例如: app-producer" :disabled="readOnly" :class="{ invalid: roleNameError }" :aria-invalid="!!roleNameError" />
<span v-if="roleNameError" class="field-error">{{ roleNameError }}</span>
</label>
<div class="actions-group" :class="{ invalid: actionsError }">
<span>权限动作</span>
<label v-for="action in actionOptions" :key="action" class="checkbox-label">
<input v-model="selectedActions" type="checkbox" :value="action" :disabled="readOnly" />
{{ action }}
</label>
<span v-if="actionsError" class="field-error actions-error">{{ actionsError }}</span>
</div>
<button @click="grantPermission" :disabled="loading || readOnly" class="btn-primary">授权</button>
</div>
</section>
<section class="permissions-table">
<h4>当前权限</h4>
<table v-if="permissionRows.length">
<thead>
<tr>
<th>角色</th>
<th>动作</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in permissionRows" :key="row.role">
<td class="role-cell">{{ row.role }}</td>
<td>
<span class="tag-list">
<span v-for="action in row.actions" :key="action" class="tag">{{ action }}</span>
</span>
</td>
<td class="row-actions">
<button @click="openTokenDialog(row.role)" class="btn-sm">Token</button>
<button @click="revokePermission(row.role)" :disabled="readOnly" class="btn-sm btn-danger">撤销</button>
</td>
</tr>
</tbody>
</table>
<div v-else class="empty-state">暂无权限记录</div>
</section>
</div>
<div v-if="showTokenDialog" class="dialog-overlay" @click="closeTokenDialog">
<div class="dialog" @click.stop>
<div class="dialog-header">
<h3>客户端 Token: {{ tokenRole }}</h3>
<button @click="closeTokenDialog" class="btn-close">×</button>
</div>
<div class="dialog-body">
<div v-if="tokenError" class="panel-error">{{ tokenError }}</div>
<div v-if="tokenIssueError" class="token-config-error" role="alert">
<strong>{{ tokenIssueError.title }}</strong>
<span>{{ tokenIssueError.message }}</span>
<small>{{ tokenIssueError.detail }}</small>
</div>
<div v-if="issuedToken" class="issued-token-box">
<div class="token-warning">Token 仅显示一次,请立即复制并保存好。</div>
<textarea :value="issuedToken.token" readonly class="token-textarea" />
<button class="btn-sm" @click="copyIssuedToken">复制 Token</button>
</div>
<section class="token-section">
<h4>签发新 Token</h4>
<div class="token-form">
<label>
角色
<input v-model="tokenRole" type="text" />
</label>
<div class="expiry-control">
<label class="checkbox-label expiry-toggle">
<input v-model="tokenExpiresUnlimited" type="checkbox" />
永不过期
</label>
<label :class="{ muted: tokenExpiresUnlimited }">
有效期(天)
<input v-model.number="tokenExpiresDays" type="number" min="1" step="1" :disabled="tokenExpiresUnlimited" />
</label>
</div>
<div class="actions-group token-actions">
<span>权限动作</span>
<label v-for="action in actionOptions" :key="action" class="checkbox-label">
<input v-model="tokenActions" type="checkbox" :value="action" />
{{ action }}
</label>
</div>
<label>
备注
<input v-model="tokenNote" type="text" placeholder="例如: 发给 rt-erp-server" />
</label>
<button @click="issueToken" :disabled="tokenLoading || readOnly" class="btn-primary">
{{ tokenLoading ? "签发中..." : "生成 Token" }}
</button>
</div>
<p class="token-message">撤销角色权限不会让已签发 JWT 立即失效;需要等待过期,或轮换 Broker 签发密钥。</p>
</section>
<section class="token-section">
<h4>签发记录</h4>
<table v-if="tokenRecords.length">
<thead>
<tr>
<th>时间</th>
<th>算法</th>
<th>过期</th>
<th>指纹</th>
<th>备注</th>
</tr>
</thead>
<tbody>
<tr v-for="record in tokenRecords" :key="record.id">
<td>{{ formatDate(record.createdAt) }}</td>
<td>{{ record.algorithm.toUpperCase() }}</td>
<td>{{ formatDate(record.expiresAt) }}</td>
<td :title="record.tokenFingerprint">{{ shortFingerprint(record.tokenFingerprint) }}</td>
<td>{{ record.note || "-" }}</td>
</tr>
</tbody>
</table>
<div v-else class="empty-state">{{ tokenLoading ? "加载中..." : "暂无签发记录" }}</div>
</section>
</div>
<div class="dialog-footer">
<button @click="closeTokenDialog" class="btn-secondary">关闭</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.permissions-panel {
height: 100%;
display: flex;
flex-direction: column;
}
.panel-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--color-border);
}
.panel-toolbar h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.scope-label {
margin-top: 4px;
color: var(--color-text-secondary);
font-size: 12px;
}
.permissions-content {
flex: 1;
overflow: auto;
padding: 16px;
}
.grant-panel,
.permissions-table {
padding: 14px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-background-secondary);
}
.permissions-table {
margin-top: 16px;
}
h4 {
margin: 0 0 12px;
font-size: 14px;
font-weight: 600;
}
.grant-row {
display: grid;
grid-template-columns: minmax(220px, 1fr) minmax(280px, 2fr) auto;
gap: 16px;
align-items: end;
}
label {
display: flex;
flex-direction: column;
gap: 6px;
color: var(--color-text-secondary);
font-size: 13px;
}
input[type="text"],
input[type="number"] {
width: 100%;
padding: 7px 10px;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background);
color: var(--color-text);
box-sizing: border-box;
}
input.invalid {
border-color: var(--color-error);
box-shadow: 0 0 0 2px var(--color-error-bg);
}
.field-error {
color: var(--color-error);
font-size: 12px;
line-height: 1.4;
}
.actions-group {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
color: var(--color-text-secondary);
font-size: 13px;
}
.actions-group.invalid {
padding: 8px;
border: 1px solid var(--color-error);
border-radius: 6px;
background: var(--color-error-bg);
}
.actions-group > span {
width: 100%;
}
.actions-error {
width: 100%;
}
.checkbox-label {
flex-direction: row;
align-items: center;
gap: 6px;
}
.expiry-control {
display: grid;
gap: 8px;
}
.expiry-toggle {
min-height: 30px;
}
.muted {
color: var(--color-text-tertiary);
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 10px 12px;
border-bottom: 1px solid var(--color-border-light);
text-align: left;
}
th {
color: var(--color-text-secondary);
font-size: 13px;
font-weight: 600;
}
.role-cell {
font-weight: 500;
}
.row-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.tag {
padding: 2px 8px;
border-radius: 4px;
background: var(--color-primary-alpha);
color: var(--color-primary);
font-size: 12px;
}
.empty-state,
.panel-placeholder {
padding: 24px;
color: var(--color-text-secondary);
text-align: center;
}
.panel-error,
.panel-notice,
.readonly-hint {
padding: 12px 16px;
margin-bottom: 12px;
border-radius: 6px;
font-size: 13px;
}
.panel-error {
background: var(--color-error-bg);
color: var(--color-error);
}
.panel-notice {
background: var(--color-success-alpha);
color: var(--color-success);
}
.readonly-hint {
background: var(--color-warning-alpha);
color: var(--color-warning);
}
.btn-primary,
.btn-secondary,
.btn-sm,
.btn-danger {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
}
.btn-primary {
background: var(--color-primary);
border-color: var(--color-primary);
color: white;
}
.btn-danger {
color: var(--color-error);
border-color: var(--color-error);
}
.dialog-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
}
.dialog {
width: min(760px, 92vw);
max-height: 86vh;
display: flex;
flex-direction: column;
border-radius: 8px;
background: var(--color-background);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid var(--color-border);
}
.dialog-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.btn-close {
border: none;
background: none;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 22px;
line-height: 1;
}
.dialog-body {
padding: 18px 20px;
overflow: auto;
}
.token-message {
margin: 0;
color: var(--color-text-secondary);
font-size: 13px;
line-height: 1.6;
}
.issued-token-box {
display: grid;
gap: 10px;
padding: 12px;
margin-bottom: 16px;
border: 1px solid var(--color-warning);
border-radius: 6px;
background: var(--color-warning-alpha);
}
.token-config-error {
display: grid;
gap: 6px;
padding: 12px 14px;
margin-bottom: 16px;
border: 1px solid var(--color-warning);
border-left-width: 4px;
border-radius: 6px;
background: var(--color-warning-alpha);
color: var(--color-text);
font-size: 13px;
line-height: 1.5;
}
.token-config-error strong {
color: var(--color-warning);
font-size: 14px;
font-weight: 700;
}
.token-config-error small {
color: var(--color-text-secondary);
font-size: 12px;
line-height: 1.5;
}
.token-warning {
color: var(--color-warning);
font-size: 13px;
font-weight: 600;
}
.token-textarea {
min-height: 96px;
padding: 8px 10px;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background);
color: var(--color-text);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 12px;
resize: vertical;
}
.token-section {
margin-top: 16px;
}
.token-form {
display: grid;
grid-template-columns: minmax(160px, 1fr) minmax(120px, 0.6fr);
gap: 12px;
align-items: end;
}
.token-actions {
grid-column: 1 / -1;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 14px 20px;
border-top: 1px solid var(--color-border);
}
button:disabled,
input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 800px) {
.grant-row,
.token-form {
grid-template-columns: 1fr;
}
}
</style>