feat(nacos): add access control support
This commit is contained in:
67 files changed
+9062
-135
No files matched your search
@@ -69,8 +69,8 @@ import { normalizeRabbitmqAddresses } from "@/lib/connection/rabbitmqAddresses";
|
||||
import { detectMqUiAuthKind, isMqAuthKindAllowedForSystem, type MqUiAuthKind } from "@/lib/connection/mqAuth";
|
||||
import { driverInstallProgressChannel, driverInstallProgressPercent, isDriverInstallProgressForOperation, type DriverInstallProgress } from "@/lib/connection/driverInstallProgressUi";
|
||||
import { requiresSqlServerLegacyCompatibilityComponent, setSqlServerLegacyCompatibilityConfig, sqlServerUsesLegacyCompatibility, SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY } from "@/lib/connection/sqlServerLegacyCompatibility";
|
||||
import { normalizeNacosEndpoint, normalizeNacosMetricsUrl } from "@/lib/nacos/nacosAdmin";
|
||||
import { nacosNamespaceIdentity, normalizeNacosNamespaceSelection, normalizeNacosNamespacesForDisplay } from "@/lib/nacos/nacosNamespaceVisibility";
|
||||
import { normalizeNacosEndpoint, normalizeNacosMetricsUrl, parseNacosManagedNamespaces } from "@/lib/nacos/nacosAdmin";
|
||||
import { loadReadableNacosNamespaces, nacosNamespaceIdentity, normalizeNacosNamespaceSelection } from "@/lib/nacos/nacosNamespaceVisibility";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowDown,
|
||||
@@ -821,11 +821,12 @@ const mqKafkaSaslMechanismOptions = [
|
||||
{ value: "SCRAM-SHA-512", label: "SCRAM-SHA-512" },
|
||||
];
|
||||
const nacosImplementation = ref<NacosImplementation>("nacos");
|
||||
// Nacos 2 and 3 expose different API planes (and Nacos 3 commonly needs a
|
||||
// separate Console address). New connections must therefore choose an
|
||||
// explicit version instead of relying on endpoint-shape guessing.
|
||||
// Nacos 2 and 3 expose different API planes. New connections must therefore
|
||||
// choose an explicit version instead of relying on endpoint-shape guessing.
|
||||
const nacosVersionMode = ref<NacosVersionMode>("v2");
|
||||
const nacosServerAddr = ref("");
|
||||
const nacosOrdinaryAccount = ref(false);
|
||||
const nacosManagedNamespacesText = ref("");
|
||||
const nacosRNacosConsoleAddr = ref("");
|
||||
const nacosHistoryEnabled = ref(false);
|
||||
const nacosConsoleAuthKind = ref<NacosRNacosConsoleAuth["kind"]>("inherit");
|
||||
@@ -1390,6 +1391,8 @@ function resetNacosFields(config?: Partial<NacosAdminConfig>) {
|
||||
const serverAddr = config?.serverAddr?.trim() || "";
|
||||
const contextPath = config?.contextPath?.trim() || "";
|
||||
nacosServerAddr.value = serverAddr && contextPath && contextPath !== "/" && !serverAddr.endsWith(contextPath) ? `${serverAddr.replace(/\/+$/, "")}/${contextPath.replace(/^\/+/, "")}` : serverAddr;
|
||||
nacosOrdinaryAccount.value = !!config?.managedNamespaces?.length;
|
||||
nacosManagedNamespacesText.value = (config?.managedNamespaces || []).join("\n");
|
||||
nacosRNacosConsoleAddr.value = config?.rnacosConsoleAddr?.trim() || "";
|
||||
nacosHistoryEnabled.value = config?.rnacosHistoryEnabled ?? !!config?.rnacosConsoleAddr;
|
||||
const consoleAuth = config?.rnacosConsoleAuth || { kind: "inherit" };
|
||||
@@ -1731,12 +1734,16 @@ function buildNacosAdminConfig(): NacosAdminConfig {
|
||||
if (nacosImplementation.value === "rnacos" && normalized.warnings.length) {
|
||||
throw new Error(t("connection.nacosRNacosOpenApiRequired"));
|
||||
}
|
||||
const rnacosExtensionsEnabled = nacosImplementation.value === "rnacos" && nacosHistoryEnabled.value;
|
||||
const rnacosConsoleConfigured = rnacosExtensionsEnabled && !!nacosRNacosConsoleAddr.value.trim();
|
||||
if (rnacosExtensionsEnabled && !rnacosConsoleConfigured) {
|
||||
const rnacosConsoleConfigured = nacosImplementation.value === "rnacos" && !!nacosRNacosConsoleAddr.value.trim();
|
||||
if (nacosImplementation.value === "rnacos" && nacosHistoryEnabled.value && !rnacosConsoleConfigured) {
|
||||
throw new Error(t("connection.nacosRNacosConsoleUrlRequired"));
|
||||
}
|
||||
let rnacosConsoleAuth: NacosRNacosConsoleAuth | undefined;
|
||||
const usesManagedNamespaces = nacosImplementation.value === "nacos" && nacosAuthKind.value === "usernamePassword" && nacosOrdinaryAccount.value;
|
||||
const managedNamespaces = usesManagedNamespaces ? parseNacosManagedNamespaces(nacosManagedNamespacesText.value) : [];
|
||||
if (usesManagedNamespaces && managedNamespaces.length === 0) {
|
||||
throw new Error(t("nacos.nacosOrdinaryNamespacesRequired"));
|
||||
}
|
||||
let metricsUrl: string | undefined;
|
||||
if (nacosMetricsMode.value === "custom") {
|
||||
try {
|
||||
@@ -1762,7 +1769,8 @@ function buildNacosAdminConfig(): NacosAdminConfig {
|
||||
versionMode: nacosImplementation.value === "nacos" ? nacosVersionMode.value : undefined,
|
||||
serverAddr: normalized.serverAddr,
|
||||
contextPath: normalized.contextPath || undefined,
|
||||
rnacosConsoleAddr: rnacosExtensionsEnabled ? nacosRNacosConsoleAddr.value.trim() || undefined : undefined,
|
||||
managedNamespaces: managedNamespaces.length ? managedNamespaces : undefined,
|
||||
rnacosConsoleAddr: rnacosConsoleConfigured ? nacosRNacosConsoleAddr.value.trim() : undefined,
|
||||
rnacosHistoryEnabled: nacosImplementation.value === "rnacos" ? nacosHistoryEnabled.value : undefined,
|
||||
rnacosConsoleAuth,
|
||||
auth: buildNacosAuth(),
|
||||
@@ -4426,7 +4434,7 @@ async function openVisibleNacosNamespacesPicker() {
|
||||
one_time: true,
|
||||
};
|
||||
await api.connectDb(draftConfig);
|
||||
const namespaces = normalizeNacosNamespacesForDisplay(await api.nacosListNamespaces(draftId));
|
||||
const namespaces = await loadReadableNacosNamespaces(draftId, api);
|
||||
visibleNacosNamespaces.value = [...namespaces].sort((left, right) => nacosNamespaceLabel(left).localeCompare(nacosNamespaceLabel(right)));
|
||||
const configured = form.value.visible_databases;
|
||||
const initialSelection = Array.isArray(configured) ? normalizeVisibleNacosNamespaceSelection(configured, visibleNacosNamespaces.value) : visibleNacosNamespaces.value.map(nacosNamespaceValue);
|
||||
@@ -4510,7 +4518,7 @@ function initialProductionDatabaseSelection(databaseNames: string[]): string[] {
|
||||
|
||||
async function loadProductionDatabaseNames(connectionId: string, config: ConnectionConfig): Promise<string[]> {
|
||||
if (config.db_type === "nacos") {
|
||||
return normalizeNacosNamespacesForDisplay(await api.nacosListNamespaces(connectionId)).map((namespace) => namespace.namespace);
|
||||
return (await loadReadableNacosNamespaces(connectionId, api)).map((namespace) => namespace.namespace);
|
||||
}
|
||||
if (config.db_type === "redis") {
|
||||
return (await api.redisListDatabases(connectionId)).map((database) => String(database.db));
|
||||
@@ -6316,6 +6324,29 @@ function openExternalUrl(url: string) {
|
||||
<PasswordInput v-model="nacosPassword" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="nacosImplementation === 'nacos' && nacosAuthKind === 'usernamePassword'" data-nacos-ordinary-user-toggle class="mt-4 border-t pt-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<span class="min-w-0">
|
||||
<span class="block text-sm font-medium">{{ t("nacos.nacosOrdinaryAccount") }}</span>
|
||||
<span class="mt-0.5 block text-xs leading-5 text-muted-foreground">{{ t("nacos.nacosOrdinaryAccountHint") }}</span>
|
||||
</span>
|
||||
<Switch v-model="nacosOrdinaryAccount" class="mt-0.5 shrink-0" />
|
||||
</div>
|
||||
<div v-if="nacosOrdinaryAccount" class="mt-3 grid gap-1.5 pl-0 sm:pl-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<Label>{{ t("nacos.nacosManagedNamespaces") }}</Label>
|
||||
<span class="text-[11px] text-muted-foreground">{{ t("nacos.nacosManagedNamespacesSeparator") }}</span>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="nacosManagedNamespacesText"
|
||||
data-nacos-managed-namespaces
|
||||
rows="2"
|
||||
class="min-h-14 resize-y rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm outline-none placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring"
|
||||
:placeholder="t('nacos.nacosManagedNamespacesPlaceholder')"
|
||||
/>
|
||||
<p class="text-[11px] leading-4 text-muted-foreground">{{ t("nacos.nacosManagedNamespacesHint") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section data-nacos-advanced-hint class="flex items-start gap-3 rounded-lg border border-dashed bg-muted/20 px-4 py-3">
|
||||
@@ -7707,38 +7738,35 @@ function openExternalUrl(url: string) {
|
||||
<span class="text-xs text-muted-foreground">{{ t("nacos.nacosEnabled") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<template v-if="nacosHistoryEnabled">
|
||||
<div class="grid gap-1.5">
|
||||
<Label>{{ t("connection.nacosRNacosConsoleUrl") }}</Label>
|
||||
<Input v-model="nacosRNacosConsoleAddr" :placeholder="t('connection.nacosRNacosConsoleUrlPlaceholder')" />
|
||||
<p class="text-[11px] leading-4 text-muted-foreground">{{ t("nacos.nacosRnacosConsoleUrlHint") }}</p>
|
||||
</div>
|
||||
<template v-if="nacosRNacosConsoleAddr.trim()">
|
||||
<div class="grid gap-1.5">
|
||||
<Label>{{ t("connection.nacosRNacosConsoleUrl") }}</Label>
|
||||
<Input v-model="nacosRNacosConsoleAddr" :placeholder="t('connection.nacosRNacosConsoleUrlPlaceholder')" />
|
||||
<p class="text-[11px] leading-4 text-muted-foreground">{{ t("nacos.nacosRnacosConsoleUrlHint") }}</p>
|
||||
<Label>{{ t("connection.nacosConsoleAuthentication") }}</Label>
|
||||
<div class="flex items-center gap-1 rounded-md border bg-muted/20 p-0.5">
|
||||
<Button type="button" size="sm" class="h-8 flex-1" :variant="nacosConsoleAuthKind === 'inherit' ? 'default' : 'ghost'" :disabled="nacosAuthKind === 'none'" @click="nacosConsoleAuthKind = 'inherit'">
|
||||
{{ t("connection.nacosConsoleAuthInherit") }}
|
||||
</Button>
|
||||
<Button type="button" size="sm" class="h-8 flex-1" :variant="nacosConsoleAuthKind === 'usernamePassword' ? 'default' : 'ghost'" @click="nacosConsoleAuthKind = 'usernamePassword'">
|
||||
{{ t("connection.nacosConsoleAuthSeparate") }}
|
||||
</Button>
|
||||
</div>
|
||||
<p v-if="nacosConsoleAuthKind === 'inherit' && nacosAuthKind === 'none'" class="text-xs text-destructive">{{ t("connection.nacosConsoleAuthPrimaryNone") }}</p>
|
||||
</div>
|
||||
<template v-if="nacosRNacosConsoleAddr.trim()">
|
||||
<div v-if="nacosConsoleAuthKind === 'usernamePassword'" class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="grid gap-1.5">
|
||||
<Label>{{ t("connection.nacosConsoleAuthentication") }}</Label>
|
||||
<div class="flex items-center gap-1 rounded-md border bg-muted/20 p-0.5">
|
||||
<Button type="button" size="sm" class="h-8 flex-1" :variant="nacosConsoleAuthKind === 'inherit' ? 'default' : 'ghost'" :disabled="nacosAuthKind === 'none'" @click="nacosConsoleAuthKind = 'inherit'">
|
||||
{{ t("connection.nacosConsoleAuthInherit") }}
|
||||
</Button>
|
||||
<Button type="button" size="sm" class="h-8 flex-1" :variant="nacosConsoleAuthKind === 'usernamePassword' ? 'default' : 'ghost'" @click="nacosConsoleAuthKind = 'usernamePassword'">
|
||||
{{ t("connection.nacosConsoleAuthSeparate") }}
|
||||
</Button>
|
||||
</div>
|
||||
<p v-if="nacosConsoleAuthKind === 'inherit' && nacosAuthKind === 'none'" class="text-xs text-destructive">{{ t("connection.nacosConsoleAuthPrimaryNone") }}</p>
|
||||
<Label>{{ t("connection.nacosConsoleUser") }}</Label>
|
||||
<Input v-model="nacosConsoleUsername" />
|
||||
</div>
|
||||
<div v-if="nacosConsoleAuthKind === 'usernamePassword'" class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="grid gap-1.5">
|
||||
<Label>{{ t("connection.nacosConsoleUser") }}</Label>
|
||||
<Input v-model="nacosConsoleUsername" />
|
||||
</div>
|
||||
<div class="grid gap-1.5">
|
||||
<Label>{{ t("connection.nacosConsolePassword") }}</Label>
|
||||
<PasswordInput v-model="nacosConsolePassword" />
|
||||
</div>
|
||||
<div class="grid gap-1.5">
|
||||
<Label>{{ t("connection.nacosConsolePassword") }}</Label>
|
||||
<PasswordInput v-model="nacosConsolePassword" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="text-[11px] leading-4 text-muted-foreground">{{ t("nacos.nacosRnacosDisabledHint") }}</p>
|
||||
</div>
|
||||
|
||||
<label class="flex items-start justify-between gap-4 border-t pt-4">
|
||||
|
||||
@@ -94,6 +94,7 @@ const ElasticsearchJsonResponsePanel = defineAsyncComponent(() => import("@/comp
|
||||
const MqAdminConsole = defineAsyncComponent(() => import("@/components/mq/MqAdminConsole.vue"));
|
||||
const MqttAdminConsole = defineAsyncComponent(() => import("@/components/mqtt/MqttAdminConsole.vue"));
|
||||
const NacosAdminConsole = defineAsyncComponent(() => import("@/components/nacos/NacosAdminConsole.vue"));
|
||||
const NacosAccessControlConsole = defineAsyncComponent(() => import("@/components/nacos/NacosAccessControlConsole.vue"));
|
||||
const NacosDashboard = defineAsyncComponent(() => import("@/components/nacos/NacosDashboard.vue"));
|
||||
const DatabaseBrowser = defineAsyncComponent(() => import("@/components/objects/DatabaseBrowser.vue"));
|
||||
const ObjectBrowser = defineAsyncComponent(() => import("@/components/objects/ObjectBrowser.vue"));
|
||||
@@ -2032,6 +2033,12 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab.mode === 'nacos-access-control'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<NacosAccessControlConsole :key="activeTab.id" :connection-id="activeTab.connectionId" :read-only="activeConnection?.read_only ?? false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ZooKeeper mode: znode browser -->
|
||||
<template v-else-if="activeTab.mode === 'zookeeper'">
|
||||
<div class="flex-1 min-h-0">
|
||||
|
||||
@@ -0,0 +1,686 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { KeyRound, Loader2, Pencil, Plus, RefreshCw, Search, ShieldCheck, Trash2, UserRound } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import NacosNamespaceMultiSelect from "@/components/nacos/NacosNamespaceMultiSelect.vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { executeWithProductionContextGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import { normalizeNacosNamespacesForDisplay } from "@/lib/nacos/nacosNamespaceVisibility";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import type { NacosConnectionInfo, NacosNamespaceInfo, NacosNamespacePrivilege, NacosOperationCapability, NacosRoleBinding, NacosUserInfo } from "@/types/nacos";
|
||||
|
||||
const props = defineProps<{
|
||||
connectionId: string;
|
||||
connectionInfo: NacosConnectionInfo | null;
|
||||
readOnly?: boolean;
|
||||
tab: "users" | "roles";
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
|
||||
type UserDialogMode = "create" | "edit" | "reset";
|
||||
|
||||
const users = ref<NacosUserInfo[]>([]);
|
||||
const usersLoading = ref(false);
|
||||
const usersError = ref("");
|
||||
const userSearch = ref("");
|
||||
const userPageNo = ref(1);
|
||||
const userPageSize = ref(20);
|
||||
const userTotal = ref(0);
|
||||
|
||||
const roles = ref<NacosRoleBinding[]>([]);
|
||||
const rolesLoading = ref(false);
|
||||
const rolesError = ref("");
|
||||
const roleUsername = ref("");
|
||||
const roleName = ref("");
|
||||
const rolePageNo = ref(1);
|
||||
const rolePageSize = ref(20);
|
||||
const roleTotal = ref(0);
|
||||
|
||||
const userDialogOpen = ref(false);
|
||||
const userDialogMode = ref<UserDialogMode>("create");
|
||||
const userDialogError = ref("");
|
||||
const userSaving = ref(false);
|
||||
const userForm = ref(createUserForm());
|
||||
const userFormNamespaces = ref<NacosNamespaceInfo[]>([]);
|
||||
const userFormNamespacesLoading = ref(false);
|
||||
const userFormNamespacesError = ref("");
|
||||
const pendingUserDelete = ref<NacosUserInfo | null>(null);
|
||||
const deletingUser = ref(false);
|
||||
const roleDialogOpen = ref(false);
|
||||
const roleDialogError = ref("");
|
||||
const roleSaving = ref(false);
|
||||
const roleForm = ref({ username: "", role: "" });
|
||||
const pendingRoleDelete = ref<NacosRoleBinding | null>(null);
|
||||
const deletingRole = ref(false);
|
||||
|
||||
const rnacosAuthOpen = ref(false);
|
||||
const rnacosAuthLoading = ref(false);
|
||||
const rnacosCaptchaImage = ref("");
|
||||
const rnacosCaptcha = ref("");
|
||||
const rnacosAuthError = ref("");
|
||||
let rnacosRetry: (() => Promise<void>) | null = null;
|
||||
|
||||
const accessControl = computed(() => props.connectionInfo?.capabilities.accessControl);
|
||||
const mode = computed(() => accessControl.value?.mode ?? "unavailable");
|
||||
const isRNacos = computed(() => mode.value === "embeddedRoles");
|
||||
const userTotalPages = computed(() => Math.max(1, Math.ceil(userTotal.value / Math.max(1, userPageSize.value))));
|
||||
const roleTotalPages = computed(() => Math.max(1, Math.ceil(roleTotal.value / Math.max(1, rolePageSize.value))));
|
||||
|
||||
function createUserForm() {
|
||||
return {
|
||||
username: "",
|
||||
password: "",
|
||||
nickname: "",
|
||||
enabled: true,
|
||||
roles: [] as string[],
|
||||
whitelistIsAll: true,
|
||||
whitelist: [] as string[],
|
||||
blacklistIsAll: false,
|
||||
blacklist: [] as string[],
|
||||
};
|
||||
}
|
||||
|
||||
function operationCapability(value: NacosOperationCapability | undefined): NacosOperationCapability {
|
||||
return value ?? { supported: false, reason: "notVerified" };
|
||||
}
|
||||
|
||||
function capabilityReason(capability: NacosOperationCapability) {
|
||||
if (props.readOnly) return t("nacos.capabilityReadOnly");
|
||||
switch (capability.reason) {
|
||||
case "implementationReadOnly":
|
||||
return t("nacos.capabilityReadOnlyWrite");
|
||||
case "versionUnsupported":
|
||||
return t("nacos.capabilityVersionUnsupported");
|
||||
case "endpointUnavailable":
|
||||
return t("nacos.capabilityEndpointUnavailable");
|
||||
default:
|
||||
return t("nacos.capabilityNotVerified");
|
||||
}
|
||||
}
|
||||
|
||||
function isWritable(capability: NacosOperationCapability | undefined) {
|
||||
return !props.readOnly && operationCapability(capability).supported;
|
||||
}
|
||||
|
||||
async function confirmAccessMutation(reviewText: string) {
|
||||
const confirmed = await executeWithProductionContextGuard({
|
||||
connection: connectionStore.getConfig(props.connectionId),
|
||||
database: "",
|
||||
reviewText,
|
||||
source: t("production.sourceAdmin"),
|
||||
execute: async () => true,
|
||||
});
|
||||
return confirmed === true;
|
||||
}
|
||||
|
||||
function listUsersCapability() {
|
||||
return operationCapability(accessControl.value?.listUsers);
|
||||
}
|
||||
|
||||
function createUserCapability() {
|
||||
return operationCapability(accessControl.value?.createUser);
|
||||
}
|
||||
|
||||
function updateUserCapability() {
|
||||
return operationCapability(accessControl.value?.updateUser);
|
||||
}
|
||||
|
||||
function deleteUserCapability() {
|
||||
return operationCapability(accessControl.value?.deleteUser);
|
||||
}
|
||||
|
||||
function listRolesCapability() {
|
||||
return operationCapability(accessControl.value?.listRoleBindings);
|
||||
}
|
||||
|
||||
function assignRoleCapability() {
|
||||
return operationCapability(accessControl.value?.assignRole);
|
||||
}
|
||||
|
||||
function removeRoleCapability() {
|
||||
return operationCapability(accessControl.value?.removeRole);
|
||||
}
|
||||
|
||||
function privilegeFromForm(): NacosNamespacePrivilege {
|
||||
return {
|
||||
// r-nacos derives this read-only state from its privilege flags; its
|
||||
// update API accepts only the whitelist and blacklist options below.
|
||||
enabled: true,
|
||||
whitelistIsAll: userForm.value.whitelistIsAll,
|
||||
whitelist: [...userForm.value.whitelist],
|
||||
blacklistIsAll: userForm.value.blacklistIsAll,
|
||||
blacklist: [...userForm.value.blacklist],
|
||||
};
|
||||
}
|
||||
|
||||
function captchaImageSource(image: string) {
|
||||
return image.startsWith("data:") ? image : `data:image/png;base64,${image}`;
|
||||
}
|
||||
|
||||
async function withRNacosAuthentication(action: () => Promise<void>) {
|
||||
try {
|
||||
await action();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!message.includes("[rnacosConsoleCaptchaRequired]")) throw error;
|
||||
rnacosRetry = action;
|
||||
await requestRNacosAuthentication();
|
||||
}
|
||||
}
|
||||
|
||||
async function requestRNacosAuthentication() {
|
||||
rnacosAuthLoading.value = true;
|
||||
rnacosAuthError.value = "";
|
||||
rnacosCaptcha.value = "";
|
||||
try {
|
||||
const challenge = await api.nacosGetRNacosConsoleCaptcha(props.connectionId);
|
||||
if (!challenge.required) {
|
||||
await api.nacosLoginRNacosConsole(props.connectionId);
|
||||
const retry = rnacosRetry;
|
||||
rnacosRetry = null;
|
||||
if (retry) await retry();
|
||||
return;
|
||||
}
|
||||
if (!challenge.image) throw new Error(t("nacos.rnacosCaptchaUnavailable"));
|
||||
rnacosCaptchaImage.value = captchaImageSource(challenge.image);
|
||||
rnacosAuthOpen.value = true;
|
||||
} catch (error) {
|
||||
rnacosAuthError.value = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
rnacosAuthLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRNacosAuthentication() {
|
||||
if (!rnacosCaptcha.value.trim()) {
|
||||
rnacosAuthError.value = t("nacos.rnacosCaptchaRequired");
|
||||
return;
|
||||
}
|
||||
rnacosAuthLoading.value = true;
|
||||
rnacosAuthError.value = "";
|
||||
try {
|
||||
await api.nacosLoginRNacosConsole(props.connectionId, rnacosCaptcha.value);
|
||||
rnacosAuthOpen.value = false;
|
||||
const retry = rnacosRetry;
|
||||
rnacosRetry = null;
|
||||
if (retry) await retry();
|
||||
} catch (error) {
|
||||
rnacosAuthError.value = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
rnacosAuthLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers(page = userPageNo.value) {
|
||||
if (!listUsersCapability().supported) return;
|
||||
usersLoading.value = true;
|
||||
usersError.value = "";
|
||||
userPageNo.value = page;
|
||||
try {
|
||||
await withRNacosAuthentication(async () => {
|
||||
const result = await api.nacosListUsers(props.connectionId, {
|
||||
username: userSearch.value.trim() || undefined,
|
||||
pageNo: userPageNo.value,
|
||||
pageSize: userPageSize.value,
|
||||
});
|
||||
users.value = result.items;
|
||||
userTotal.value = result.totalCount;
|
||||
});
|
||||
} catch (error) {
|
||||
usersError.value = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
usersLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRoles(page = rolePageNo.value) {
|
||||
if (!listRolesCapability().supported) return;
|
||||
rolesLoading.value = true;
|
||||
rolesError.value = "";
|
||||
rolePageNo.value = page;
|
||||
try {
|
||||
const result = await api.nacosListRoleBindings(props.connectionId, {
|
||||
username: roleUsername.value.trim() || undefined,
|
||||
role: roleName.value.trim() || undefined,
|
||||
pageNo: rolePageNo.value,
|
||||
pageSize: rolePageSize.value,
|
||||
});
|
||||
roles.value = result.items;
|
||||
roleTotal.value = result.totalCount;
|
||||
} catch (error) {
|
||||
rolesError.value = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
rolesLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUserFormNamespaces(force = false) {
|
||||
if (!isRNacos.value || userFormNamespacesLoading.value || (userFormNamespaces.value.length && !force)) return;
|
||||
userFormNamespacesLoading.value = true;
|
||||
userFormNamespacesError.value = "";
|
||||
try {
|
||||
userFormNamespaces.value = normalizeNacosNamespacesForDisplay(await api.nacosListNamespaces(props.connectionId));
|
||||
} catch (error) {
|
||||
userFormNamespacesError.value = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
userFormNamespacesLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateUser() {
|
||||
userDialogMode.value = "create";
|
||||
userDialogError.value = "";
|
||||
userForm.value = createUserForm();
|
||||
userDialogOpen.value = true;
|
||||
void loadUserFormNamespaces();
|
||||
}
|
||||
|
||||
function openEditUser(user: NacosUserInfo) {
|
||||
userDialogMode.value = isRNacos.value ? "edit" : "reset";
|
||||
userDialogError.value = "";
|
||||
const privilege = user.namespacePrivilege;
|
||||
userForm.value = {
|
||||
username: user.username,
|
||||
password: "",
|
||||
nickname: user.nickname ?? "",
|
||||
enabled: user.enabled ?? true,
|
||||
roles: [...user.roles],
|
||||
whitelistIsAll: privilege?.whitelistIsAll ?? true,
|
||||
whitelist: [...(privilege?.whitelist ?? [])],
|
||||
blacklistIsAll: privilege?.blacklistIsAll ?? false,
|
||||
blacklist: [...(privilege?.blacklist ?? [])],
|
||||
};
|
||||
userDialogOpen.value = true;
|
||||
void loadUserFormNamespaces();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.connectionId,
|
||||
() => {
|
||||
userFormNamespaces.value = [];
|
||||
userFormNamespacesError.value = "";
|
||||
},
|
||||
);
|
||||
|
||||
function toggleRNacosRole(role: string, checked: boolean) {
|
||||
const next = new Set(userForm.value.roles);
|
||||
if (checked) next.add(role);
|
||||
else next.delete(role);
|
||||
userForm.value.roles = [...next];
|
||||
}
|
||||
|
||||
async function saveUser() {
|
||||
if (userSaving.value) return;
|
||||
const username = userForm.value.username.trim();
|
||||
if (!username || (userDialogMode.value !== "edit" && !userForm.value.password)) {
|
||||
userDialogError.value = t("nacos.userCredentialsRequired");
|
||||
return;
|
||||
}
|
||||
if (!(await confirmAccessMutation(`${userDialogMode.value === "create" ? t("nacos.createUser") : t("nacos.save")}: ${username}`))) return;
|
||||
userSaving.value = true;
|
||||
userDialogError.value = "";
|
||||
try {
|
||||
await withRNacosAuthentication(async () => {
|
||||
if (userDialogMode.value === "create") {
|
||||
await api.nacosCreateUser(props.connectionId, {
|
||||
username,
|
||||
password: userForm.value.password,
|
||||
nickname: isRNacos.value ? userForm.value.nickname.trim() || undefined : undefined,
|
||||
enabled: isRNacos.value ? userForm.value.enabled : undefined,
|
||||
roles: isRNacos.value ? userForm.value.roles : [],
|
||||
namespacePrivilege: isRNacos.value ? privilegeFromForm() : undefined,
|
||||
});
|
||||
} else {
|
||||
await api.nacosUpdateUser(props.connectionId, {
|
||||
username,
|
||||
password: userForm.value.password,
|
||||
nickname: isRNacos.value ? userForm.value.nickname.trim() || undefined : undefined,
|
||||
enabled: isRNacos.value ? userForm.value.enabled : undefined,
|
||||
roles: isRNacos.value ? userForm.value.roles : undefined,
|
||||
namespacePrivilege: isRNacos.value ? privilegeFromForm() : undefined,
|
||||
});
|
||||
}
|
||||
userDialogOpen.value = false;
|
||||
await loadUsers(1);
|
||||
});
|
||||
} catch (error) {
|
||||
userDialogError.value = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
userSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser() {
|
||||
const user = pendingUserDelete.value;
|
||||
if (!user || deletingUser.value) return;
|
||||
if (!(await confirmAccessMutation(`${t("nacos.deleteUser")}: ${user.username}`))) return;
|
||||
deletingUser.value = true;
|
||||
try {
|
||||
await withRNacosAuthentication(async () => {
|
||||
await api.nacosDeleteUser(props.connectionId, user.username);
|
||||
pendingUserDelete.value = null;
|
||||
await loadUsers(Math.min(userPageNo.value, userTotalPages.value));
|
||||
});
|
||||
} catch (error) {
|
||||
usersError.value = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
deletingUser.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openAssignRole() {
|
||||
roleDialogError.value = "";
|
||||
roleForm.value = { username: roleUsername.value.trim(), role: "" };
|
||||
roleDialogOpen.value = true;
|
||||
}
|
||||
|
||||
async function assignRole() {
|
||||
if (roleSaving.value) return;
|
||||
if (!roleForm.value.username.trim() || !roleForm.value.role.trim()) {
|
||||
roleDialogError.value = t("nacos.roleBindingRequired");
|
||||
return;
|
||||
}
|
||||
if (!(await confirmAccessMutation(`${t("nacos.assignRole")}: ${roleForm.value.username.trim()} · ${roleForm.value.role.trim()}`))) return;
|
||||
roleSaving.value = true;
|
||||
roleDialogError.value = "";
|
||||
try {
|
||||
await api.nacosAssignRole(props.connectionId, { username: roleForm.value.username.trim(), role: roleForm.value.role.trim() });
|
||||
roleDialogOpen.value = false;
|
||||
await loadRoles(1);
|
||||
} catch (error) {
|
||||
roleDialogError.value = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
roleSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRole() {
|
||||
const binding = pendingRoleDelete.value;
|
||||
if (!binding || deletingRole.value) return;
|
||||
if (!(await confirmAccessMutation(`${t("nacos.removeRole")}: ${binding.username} · ${binding.role}`))) return;
|
||||
deletingRole.value = true;
|
||||
try {
|
||||
await api.nacosRemoveRole(props.connectionId, binding);
|
||||
pendingRoleDelete.value = null;
|
||||
await loadRoles(Math.min(rolePageNo.value, roleTotalPages.value));
|
||||
} catch (error) {
|
||||
rolesError.value = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
deletingRole.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.tab,
|
||||
(tab) => {
|
||||
if (tab === "users") void loadUsers(1);
|
||||
else void loadRoles(1);
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.connectionId,
|
||||
() => {
|
||||
users.value = [];
|
||||
roles.value = [];
|
||||
userPageNo.value = 1;
|
||||
rolePageNo.value = 1;
|
||||
if (props.tab === "users") void loadUsers(1);
|
||||
else void loadRoles(1);
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (props.tab === "users") void loadUsers(1);
|
||||
else void loadRoles(1);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex h-full min-h-0 flex-col bg-background">
|
||||
<template v-if="tab === 'users'">
|
||||
<header class="flex shrink-0 flex-wrap items-center gap-2 border-b bg-muted/20 px-4 py-2.5">
|
||||
<div class="relative min-w-48 max-w-xl flex-1">
|
||||
<Input v-model="userSearch" class="h-8 pr-8" :placeholder="t('nacos.searchUsers')" @keyup.enter="loadUsers(1)" />
|
||||
<Search class="pointer-events-none absolute right-2.5 top-2 h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div class="flex-1" />
|
||||
<Button size="sm" variant="outline" class="h-8 w-8 px-0" :disabled="usersLoading || !listUsersCapability().supported" :title="!listUsersCapability().supported ? capabilityReason(listUsersCapability()) : t('nacos.refresh')" :aria-label="t('nacos.refresh')" @click="loadUsers(1)">
|
||||
<Loader2 v-if="usersLoading" class="h-3.5 w-3.5 animate-spin" />
|
||||
<RefreshCw v-else class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button size="sm" class="h-8 gap-1.5" :disabled="!isWritable(createUserCapability())" :title="!isWritable(createUserCapability()) ? capabilityReason(createUserCapability()) : undefined" @click="openCreateUser">
|
||||
<Plus class="h-3.5 w-3.5" />
|
||||
{{ t("nacos.createUser") }}
|
||||
</Button>
|
||||
</header>
|
||||
<div v-if="!listUsersCapability().supported" class="border-b bg-muted/30 px-3 py-2 text-xs text-muted-foreground">{{ capabilityReason(listUsersCapability()) }}</div>
|
||||
<div v-if="usersError" class="border-b px-3 py-2 text-xs text-destructive">{{ usersError }}</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto">
|
||||
<table class="w-full min-w-[680px] text-sm">
|
||||
<thead class="sticky top-0 z-10 bg-muted/70 text-left text-xs text-muted-foreground">
|
||||
<tr class="border-b">
|
||||
<th class="px-3 py-2 font-medium">{{ t("nacos.username") }}</th>
|
||||
<th class="px-3 py-2 font-medium">{{ t("nacos.nickname") }}</th>
|
||||
<th class="px-3 py-2 font-medium">{{ t("nacos.roles") }}</th>
|
||||
<th class="px-3 py-2 font-medium">{{ t("nacos.state") }}</th>
|
||||
<th class="w-32 px-3 py-2 text-right font-medium">{{ t("nacos.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in users" :key="user.username" class="border-b hover:bg-accent/40">
|
||||
<td class="px-3 py-2 font-medium">{{ user.username }}</td>
|
||||
<td class="px-3 py-2 text-muted-foreground">{{ user.nickname || "-" }}</td>
|
||||
<td class="px-3 py-2">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Badge v-for="role in user.roles" :key="role" variant="outline" class="font-normal">{{ isRNacos ? t(`nacos.rnacosRole${role}`) : role }}</Badge
|
||||
><span v-if="!user.roles.length" class="text-muted-foreground">-</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<Badge v-if="isRNacos" :variant="user.enabled === false ? 'outline' : 'secondary'" :class="user.enabled === false ? 'text-muted-foreground' : 'text-emerald-700 dark:text-emerald-300'">{{ user.enabled === false ? t("nacos.disabled") : t("nacos.enabled") }}</Badge
|
||||
><span v-else class="text-muted-foreground">-</span>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<div class="flex justify-end gap-1">
|
||||
<Button size="sm" variant="ghost" class="h-7 w-7 p-0" :disabled="!isWritable(updateUserCapability())" :title="isRNacos ? t('nacos.editUser') : t('nacos.resetUserPassword')" @click="openEditUser(user)"
|
||||
><Pencil v-if="isRNacos" class="h-3.5 w-3.5" /><KeyRound v-else class="h-3.5 w-3.5" /></Button
|
||||
><Button size="sm" variant="ghost" class="h-7 w-7 p-0 text-destructive hover:text-destructive" :disabled="!isWritable(deleteUserCapability())" :title="t('nacos.deleteUser')" @click="pendingUserDelete = user"><Trash2 class="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="!usersLoading && !users.length" class="flex h-full min-h-48 items-center justify-center text-sm text-muted-foreground"><UserRound class="mr-2 h-4 w-4" />{{ t("nacos.noUsers") }}</div>
|
||||
</div>
|
||||
<footer class="flex shrink-0 items-center justify-between border-t bg-muted/10 px-4 py-2 text-xs text-muted-foreground">
|
||||
<span>{{ t("nacos.total", { count: userTotal }) }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" class="h-7" :disabled="userPageNo <= 1 || usersLoading" @click="loadUsers(userPageNo - 1)">{{ t("nacos.prev") }}</Button
|
||||
><span>{{ userPageNo }} / {{ userTotalPages }}</span
|
||||
><Button size="sm" variant="outline" class="h-7" :disabled="userPageNo >= userTotalPages || usersLoading" @click="loadUsers(userPageNo + 1)">{{ t("nacos.next") }}</Button>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<header class="flex shrink-0 flex-wrap items-center gap-2 border-b bg-muted/20 px-4 py-2.5">
|
||||
<Input v-model="roleUsername" class="h-8 min-w-44 max-w-sm flex-1" :placeholder="t('nacos.filterUsername')" @keyup.enter="loadRoles(1)" />
|
||||
<Input v-model="roleName" class="h-8 min-w-44 max-w-sm flex-1" :placeholder="t('nacos.filterRole')" @keyup.enter="loadRoles(1)" />
|
||||
<div class="flex-1" />
|
||||
<Button size="sm" variant="outline" class="h-8 w-8 px-0" :disabled="rolesLoading || !listRolesCapability().supported" :title="!listRolesCapability().supported ? capabilityReason(listRolesCapability()) : t('nacos.refresh')" :aria-label="t('nacos.refresh')" @click="loadRoles(1)"
|
||||
><Loader2 v-if="rolesLoading" class="h-3.5 w-3.5 animate-spin" /><RefreshCw v-else class="h-3.5 w-3.5"
|
||||
/></Button>
|
||||
<Button size="sm" class="h-8 gap-1.5" :disabled="!isWritable(assignRoleCapability())" :title="!isWritable(assignRoleCapability()) ? capabilityReason(assignRoleCapability()) : undefined" @click="openAssignRole"><Plus class="h-3.5 w-3.5" />{{ t("nacos.assignRole") }}</Button>
|
||||
</header>
|
||||
<div v-if="rolesError" class="border-b px-3 py-2 text-xs text-destructive">{{ rolesError }}</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto">
|
||||
<table class="w-full min-w-[520px] text-sm">
|
||||
<thead class="sticky top-0 z-10 bg-muted/70 text-left text-xs text-muted-foreground">
|
||||
<tr class="border-b">
|
||||
<th class="px-3 py-2 font-medium">{{ t("nacos.username") }}</th>
|
||||
<th class="px-3 py-2 font-medium">{{ t("nacos.role") }}</th>
|
||||
<th class="w-24 px-3 py-2 text-right font-medium">{{ t("nacos.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="binding in roles" :key="`${binding.username}:${binding.role}`" class="border-b hover:bg-accent/40">
|
||||
<td class="px-3 py-2 font-medium">{{ binding.username }}</td>
|
||||
<td class="px-3 py-2">
|
||||
<Badge variant="outline" class="font-normal">{{ binding.role }}</Badge>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right">
|
||||
<span :title="binding.role === 'ROLE_ADMIN' ? t('nacos.protectedRole') : undefined"
|
||||
><Button size="sm" variant="ghost" class="h-7 w-7 p-0 text-destructive hover:text-destructive" :disabled="binding.role === 'ROLE_ADMIN' || !isWritable(removeRoleCapability())" :aria-label="t('nacos.removeRole')" @click="pendingRoleDelete = binding"
|
||||
><Trash2 class="h-3.5 w-3.5" /></Button
|
||||
></span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="!rolesLoading && !roles.length" class="flex h-full min-h-48 items-center justify-center text-sm text-muted-foreground"><ShieldCheck class="mr-2 h-4 w-4" />{{ t("nacos.noRoleBindings") }}</div>
|
||||
</div>
|
||||
<footer class="flex shrink-0 items-center justify-between border-t bg-muted/10 px-4 py-2 text-xs text-muted-foreground">
|
||||
<span>{{ t("nacos.total", { count: roleTotal }) }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" class="h-7" :disabled="rolePageNo <= 1 || rolesLoading" @click="loadRoles(rolePageNo - 1)">{{ t("nacos.prev") }}</Button
|
||||
><span>{{ rolePageNo }} / {{ roleTotalPages }}</span
|
||||
><Button size="sm" variant="outline" class="h-7" :disabled="rolePageNo >= roleTotalPages || rolesLoading" @click="loadRoles(rolePageNo + 1)">{{ t("nacos.next") }}</Button>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<Dialog v-model:open="userDialogOpen"
|
||||
><DialogContent class="max-h-[88vh] overflow-auto sm:max-w-xl"
|
||||
><DialogHeader
|
||||
><DialogTitle>{{ t(`nacos.${userDialogMode}UserTitle`) }}</DialogTitle
|
||||
><DialogDescription>{{ t(`nacos.${userDialogMode}UserDescription`) }}</DialogDescription></DialogHeader
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-1.5">
|
||||
<Label for="nacos-user-name">{{ t("nacos.username") }}</Label
|
||||
><Input id="nacos-user-name" v-model="userForm.username" :disabled="userDialogMode !== 'create'" autocomplete="off" />
|
||||
</div>
|
||||
<div class="grid gap-1.5">
|
||||
<Label for="nacos-user-password">{{ t("nacos.password") }}</Label
|
||||
><Input id="nacos-user-password" v-model="userForm.password" type="password" autocomplete="new-password" :placeholder="userDialogMode === 'edit' ? t('nacos.passwordUnchanged') : undefined" />
|
||||
</div>
|
||||
<template v-if="isRNacos && userDialogMode !== 'reset'"
|
||||
><div class="grid gap-1.5">
|
||||
<Label for="nacos-user-nickname">{{ t("nacos.nickname") }}</Label
|
||||
><Input id="nacos-user-nickname" v-model="userForm.nickname" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<div>
|
||||
<div class="text-sm font-medium">{{ t("nacos.enabled") }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ t("nacos.rnacosUserEnabledHint") }}</div>
|
||||
</div>
|
||||
<Switch v-model="userForm.enabled" />
|
||||
</div>
|
||||
<fieldset class="space-y-2 rounded-md border p-3">
|
||||
<legend class="px-1 text-sm font-medium">{{ t("nacos.roles") }}</legend>
|
||||
<label v-for="role in ['0', '1', '2']" :key="role" class="flex items-start gap-2 text-sm"
|
||||
><input type="checkbox" :checked="userForm.roles.includes(role)" @change="toggleRNacosRole(role, ($event.target as HTMLInputElement).checked)" /><span
|
||||
><span class="font-medium">{{ t(`nacos.rnacosRole${role}`) }}</span
|
||||
><span class="ml-2 text-xs text-muted-foreground">{{ t(`nacos.rnacosRole${role}Hint`) }}</span></span
|
||||
></label
|
||||
>
|
||||
</fieldset>
|
||||
<fieldset class="space-y-3 rounded-md border p-3">
|
||||
<legend class="px-1 text-sm font-medium">{{ t("nacos.namespacePrivileges") }}</legend>
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>{{ t("nacos.namespaceWhitelist") }}</Label
|
||||
><label class="flex items-center gap-2 text-xs text-muted-foreground"><Switch v-model="userForm.whitelistIsAll" size="sm" />{{ t("nacos.allNamespaces") }}</label>
|
||||
</div>
|
||||
<NacosNamespaceMultiSelect v-if="!userForm.whitelistIsAll" v-model="userForm.whitelist" :namespaces="userFormNamespaces" :loading="userFormNamespacesLoading" :error="userFormNamespacesError" @retry="loadUserFormNamespaces(true)" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>{{ t("nacos.namespaceBlacklist") }}</Label
|
||||
><label class="flex items-center gap-2 text-xs text-muted-foreground"><Switch v-model="userForm.blacklistIsAll" size="sm" />{{ t("nacos.allNamespaces") }}</label>
|
||||
</div>
|
||||
<NacosNamespaceMultiSelect v-if="!userForm.blacklistIsAll" v-model="userForm.blacklist" :namespaces="userFormNamespaces" :loading="userFormNamespacesLoading" :error="userFormNamespacesError" @retry="loadUserFormNamespaces(true)" />
|
||||
</div></fieldset
|
||||
></template>
|
||||
<p v-if="userDialogError" class="text-sm text-destructive">{{ userDialogError }}</p>
|
||||
</div>
|
||||
<DialogFooter
|
||||
><Button variant="outline" :disabled="userSaving" @click="userDialogOpen = false">{{ t("nacos.cancel") }}</Button
|
||||
><Button :disabled="userSaving" @click="saveUser"><Loader2 v-if="userSaving" class="mr-2 h-4 w-4 animate-spin" />{{ t("nacos.save") }}</Button></DialogFooter
|
||||
></DialogContent
|
||||
></Dialog
|
||||
>
|
||||
|
||||
<Dialog v-model:open="roleDialogOpen"
|
||||
><DialogContent class="sm:max-w-md"
|
||||
><DialogHeader
|
||||
><DialogTitle>{{ t("nacos.assignRoleTitle") }}</DialogTitle
|
||||
><DialogDescription>{{ t("nacos.assignRoleDescription") }}</DialogDescription></DialogHeader
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-1.5">
|
||||
<Label for="nacos-role-user">{{ t("nacos.username") }}</Label
|
||||
><Input id="nacos-role-user" v-model="roleForm.username" />
|
||||
</div>
|
||||
<div class="grid gap-1.5">
|
||||
<Label for="nacos-role-name">{{ t("nacos.role") }}</Label
|
||||
><Input id="nacos-role-name" v-model="roleForm.role" placeholder="ROLE_USER" />
|
||||
</div>
|
||||
<p v-if="roleDialogError" class="text-sm text-destructive">{{ roleDialogError }}</p>
|
||||
</div>
|
||||
<DialogFooter
|
||||
><Button variant="outline" :disabled="roleSaving" @click="roleDialogOpen = false">{{ t("nacos.cancel") }}</Button
|
||||
><Button :disabled="roleSaving" @click="assignRole"><Loader2 v-if="roleSaving" class="mr-2 h-4 w-4 animate-spin" />{{ t("nacos.assignRole") }}</Button></DialogFooter
|
||||
></DialogContent
|
||||
></Dialog
|
||||
>
|
||||
|
||||
<Dialog :open="!!pendingUserDelete" @update:open="!$event && (pendingUserDelete = null)"
|
||||
><DialogContent class="sm:max-w-md"
|
||||
><DialogHeader
|
||||
><DialogTitle>{{ t("nacos.deleteUserTitle") }}</DialogTitle
|
||||
><DialogDescription>{{ t("nacos.deleteUserDescription", { username: pendingUserDelete?.username }) }}</DialogDescription></DialogHeader
|
||||
><DialogFooter
|
||||
><Button variant="outline" :disabled="deletingUser" @click="pendingUserDelete = null">{{ t("nacos.cancel") }}</Button
|
||||
><Button variant="destructive" :disabled="deletingUser" @click="deleteUser"><Loader2 v-if="deletingUser" class="mr-2 h-4 w-4 animate-spin" />{{ t("nacos.delete") }}</Button></DialogFooter
|
||||
></DialogContent
|
||||
></Dialog
|
||||
>
|
||||
<Dialog :open="!!pendingRoleDelete" @update:open="!$event && (pendingRoleDelete = null)"
|
||||
><DialogContent class="sm:max-w-md"
|
||||
><DialogHeader
|
||||
><DialogTitle>{{ t("nacos.removeRoleTitle") }}</DialogTitle
|
||||
><DialogDescription>{{ t("nacos.removeRoleDescription", pendingRoleDelete ?? {}) }}</DialogDescription></DialogHeader
|
||||
><DialogFooter
|
||||
><Button variant="outline" :disabled="deletingRole" @click="pendingRoleDelete = null">{{ t("nacos.cancel") }}</Button
|
||||
><Button variant="destructive" :disabled="deletingRole" @click="removeRole"><Loader2 v-if="deletingRole" class="mr-2 h-4 w-4 animate-spin" />{{ t("nacos.removeRole") }}</Button></DialogFooter
|
||||
></DialogContent
|
||||
></Dialog
|
||||
>
|
||||
<Dialog v-model:open="rnacosAuthOpen"
|
||||
><DialogContent class="sm:max-w-sm"
|
||||
><DialogHeader
|
||||
><DialogTitle>{{ t("nacos.rnacosConsoleAuthTitle") }}</DialogTitle
|
||||
><DialogDescription>{{ t("nacos.rnacosAccessControlAuthDescription") }}</DialogDescription></DialogHeader
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<img v-if="rnacosCaptchaImage" :src="rnacosCaptchaImage" class="h-16 w-full rounded border object-contain" :alt="t('nacos.rnacosCaptchaLabel')" /><Input v-model="rnacosCaptcha" :placeholder="t('nacos.rnacosCaptchaPlaceholder')" @keyup.enter="submitRNacosAuthentication" />
|
||||
<p v-if="rnacosAuthError" class="text-sm text-destructive">{{ rnacosAuthError }}</p>
|
||||
</div>
|
||||
<DialogFooter
|
||||
><Button variant="outline" :disabled="rnacosAuthLoading" @click="requestRNacosAuthentication">{{ t("nacos.rnacosRefreshCaptcha") }}</Button
|
||||
><Button :disabled="rnacosAuthLoading" @click="submitRNacosAuthentication"><Loader2 v-if="rnacosAuthLoading" class="mr-2 h-4 w-4 animate-spin" />{{ t("nacos.rnacosConsoleAuthSubmit") }}</Button></DialogFooter
|
||||
></DialogContent
|
||||
></Dialog
|
||||
>
|
||||
</template>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { KeyRound, Loader2, RefreshCw, ShieldCheck, UsersRound } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import NacosAccessControl from "@/components/nacos/NacosAccessControl.vue";
|
||||
import NacosRoleAccessControl from "@/components/nacos/NacosRoleAccessControl.vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import type { NacosConnectionInfo } from "@/types/nacos";
|
||||
|
||||
const props = defineProps<{
|
||||
connectionId: string;
|
||||
readOnly?: boolean;
|
||||
}>();
|
||||
|
||||
type AccessTab = "users" | "roles";
|
||||
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
const connectionInfo = ref<NacosConnectionInfo | null>(null);
|
||||
const connectionError = ref("");
|
||||
const loading = ref(false);
|
||||
const activeTab = ref<AccessTab>("users");
|
||||
|
||||
const accessControl = computed(() => connectionInfo.value?.capabilities.accessControl);
|
||||
const supportsUsers = computed(() => accessControl.value?.listUsers.supported === true);
|
||||
const supportsRoles = computed(() => accessControl.value?.mode === "roleBindings" && accessControl.value.listRoleBindings.supported === true);
|
||||
const enhancedWorkspace = computed(() => accessControl.value?.enhancedWorkspace === true);
|
||||
const permissionWorkspaceUnavailable = computed(() => accessControl.value?.mode === "roleBindings" && accessControl.value.listPermissions?.supported === false && accessControl.value.listPermissions.reason !== "versionUnsupported");
|
||||
|
||||
async function loadConnectionInfo() {
|
||||
loading.value = true;
|
||||
connectionError.value = "";
|
||||
try {
|
||||
await connectionStore.ensureConnected(props.connectionId);
|
||||
connectionInfo.value = await api.nacosTestConnection(props.connectionId);
|
||||
if (!supportsUsers.value && supportsRoles.value) activeTab.value = "roles";
|
||||
} catch (error) {
|
||||
connectionInfo.value = null;
|
||||
connectionError.value = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.connectionId,
|
||||
() => void loadConnectionInfo(),
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex h-full min-h-0 flex-col bg-background">
|
||||
<header class="flex h-14 shrink-0 flex-wrap items-center gap-3 border-b px-4">
|
||||
<div class="flex rounded-md border p-0.5 shadow-sm">
|
||||
<Button size="sm" class="h-8 gap-1.5 px-3 text-sm" :variant="activeTab === 'users' ? 'secondary' : 'ghost'" :disabled="!supportsUsers" @click="activeTab = 'users'">
|
||||
<UsersRound class="h-4 w-4" />
|
||||
{{ t("nacos.users") }}
|
||||
</Button>
|
||||
<Button v-if="supportsRoles" size="sm" class="h-8 gap-1.5 px-3 text-sm" :variant="activeTab === 'roles' ? 'secondary' : 'ghost'" @click="activeTab = 'roles'">
|
||||
<KeyRound class="h-4 w-4" />
|
||||
{{ t("nacos.roleBindings") }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="hidden h-5 w-px bg-border sm:block" />
|
||||
<div class="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<ShieldCheck class="h-3.5 w-3.5 shrink-0 text-sky-600" />
|
||||
<span class="font-medium text-foreground/75">{{ t("nacos.accessControlTitle") }}</span>
|
||||
<span class="hidden truncate lg:inline">{{ t("nacos.accessControlDescription") }}</span>
|
||||
</div>
|
||||
<Badge v-if="connectionInfo?.serverVersion" variant="secondary">{{ connectionInfo.serverVersion }}</Badge>
|
||||
<div class="flex-1" />
|
||||
<Badge v-if="readOnly" variant="outline">{{ t("nacos.readOnly") }}</Badge>
|
||||
<Button size="sm" variant="outline" class="h-8 gap-1.5" :disabled="loading" @click="loadConnectionInfo">
|
||||
<RefreshCw class="h-3.5 w-3.5" :class="loading ? 'animate-spin' : ''" />
|
||||
{{ t("nacos.refresh") }}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div v-if="permissionWorkspaceUnavailable" class="border-b border-amber-300 bg-amber-50 px-4 py-2 text-xs text-amber-900 dark:bg-amber-950/20 dark:text-amber-200">{{ t("nacos.accessPermissionEndpointUnavailable") }}</div>
|
||||
<div v-if="connectionError" class="mx-4 mt-3 rounded border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">{{ connectionError }}</div>
|
||||
<div v-else-if="loading && !connectionInfo" class="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||
{{ t("nacos.loading") }}
|
||||
</div>
|
||||
<template v-else-if="connectionInfo">
|
||||
<div v-if="!supportsUsers && !supportsRoles" class="m-4 rounded border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">{{ t("nacos.accessControlUnavailable") }}</div>
|
||||
<NacosRoleAccessControl v-if="enhancedWorkspace && accessControl" :connection-id="connectionId" :capabilities="accessControl" :read-only="readOnly" :tab="activeTab" @select-user="activeTab = 'users'" @select-role="activeTab = 'roles'" />
|
||||
<NacosAccessControl v-else-if="supportsUsers || supportsRoles" :connection-id="connectionId" :connection-info="connectionInfo" :read-only="readOnly" :tab="activeTab" />
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
} from "@/lib/nacos/nacosAdmin";
|
||||
import { createNacosNamespaceRequestGuard, subscribeNacosNamespacesChanged, type NacosNamespacesChangedDetail } from "@/lib/nacos/nacosNamespaceCache";
|
||||
import { nacosInstanceMatchesPatch, nacosInstanceRefIdentity, nacosIpAddressIsValid, nacosServiceDetailMatches } from "@/lib/nacos/nacosServiceManagement";
|
||||
import { nacosNamespaceIdentity, normalizeNacosNamespacesForDisplay } from "@/lib/nacos/nacosNamespaceVisibility";
|
||||
import { loadReadableNacosNamespaces, nacosNamespaceIdentity } from "@/lib/nacos/nacosNamespaceVisibility";
|
||||
import { copyToClipboard, readTextFromClipboard } from "@/lib/common/clipboard";
|
||||
import { trimmedSelectionLayer } from "@/lib/editor/codemirrorTrimmedSelectionLayer";
|
||||
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
@@ -1112,9 +1112,9 @@ async function loadBatchNamespaces(options: { force?: boolean } = {}) {
|
||||
const connectionId = props.connectionId;
|
||||
const requestId = batchNamespacesRequestGuard.start(connectionId);
|
||||
try {
|
||||
const namespaces = await api.nacosListNamespaces(connectionId);
|
||||
const namespaces = await loadReadableNacosNamespaces(connectionId, api);
|
||||
if (!batchNamespacesRequestGuard.isCurrent(requestId, props.connectionId)) return;
|
||||
batchNamespaces.value = normalizeNacosNamespacesForDisplay(namespaces);
|
||||
batchNamespaces.value = namespaces;
|
||||
} catch (error) {
|
||||
if (!batchNamespacesRequestGuard.isCurrent(requestId, props.connectionId)) return;
|
||||
batchError.value = error instanceof Error ? error.message : String(error);
|
||||
@@ -1126,9 +1126,9 @@ async function loadBatchTargetNamespaces(connectionId: string, options: { force?
|
||||
if (!options.force && batchTargetConnectionId.value === connectionId && batchTargetNamespaces.value.length) return;
|
||||
const requestId = batchTargetNamespacesRequestGuard.start(connectionId);
|
||||
try {
|
||||
const namespaces = await api.nacosListNamespaces(connectionId);
|
||||
const namespaces = await loadReadableNacosNamespaces(connectionId, api);
|
||||
if (!batchTargetNamespacesRequestGuard.isCurrent(requestId, connectionId) || batchTargetConnectionId.value !== connectionId) return;
|
||||
batchTargetNamespaces.value = normalizeNacosNamespacesForDisplay(namespaces);
|
||||
batchTargetNamespaces.value = namespaces;
|
||||
} catch (error) {
|
||||
if (!batchTargetNamespacesRequestGuard.isCurrent(requestId, connectionId) || batchTargetConnectionId.value !== connectionId) return;
|
||||
batchError.value = error instanceof Error ? error.message : String(error);
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { Check, ChevronDown, Loader2, Search, X } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { nacosNamespaceIdentity, normalizeNacosNamespacesForDisplay } from "@/lib/nacos/nacosNamespaceVisibility";
|
||||
import type { NacosNamespaceInfo } from "@/types/nacos";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string[];
|
||||
namespaces: NacosNamespaceInfo[];
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
}>(),
|
||||
{
|
||||
loading: false,
|
||||
error: "",
|
||||
disabled: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: string[]];
|
||||
retry: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const open = ref(false);
|
||||
const search = ref("");
|
||||
|
||||
const options = computed(() => {
|
||||
const normalized = normalizeNacosNamespacesForDisplay(props.namespaces).map((item) => ({
|
||||
value: nacosNamespaceIdentity(item.namespace),
|
||||
label: item.namespaceShowName || nacosNamespaceIdentity(item.namespace),
|
||||
}));
|
||||
const seen = new Set(normalized.map((item) => item.value));
|
||||
for (const value of props.modelValue) {
|
||||
if (!seen.has(value)) normalized.push({ value, label: value });
|
||||
}
|
||||
return normalized;
|
||||
});
|
||||
|
||||
const optionLabels = computed(() => new Map(options.value.map((item) => [item.value, item.label])));
|
||||
const selected = computed(() => new Set(props.modelValue));
|
||||
const filteredOptions = computed(() => {
|
||||
const query = search.value.trim().toLocaleLowerCase();
|
||||
if (!query) return options.value;
|
||||
return options.value.filter((item) => item.value.toLocaleLowerCase().includes(query) || item.label.toLocaleLowerCase().includes(query));
|
||||
});
|
||||
|
||||
watch(open, (value) => {
|
||||
if (!value) search.value = "";
|
||||
});
|
||||
|
||||
function toggle(value: string) {
|
||||
const next = new Set(props.modelValue);
|
||||
if (next.has(value)) next.delete(value);
|
||||
else next.add(value);
|
||||
emit("update:modelValue", [...next]);
|
||||
}
|
||||
|
||||
function remove(value: string) {
|
||||
emit(
|
||||
"update:modelValue",
|
||||
props.modelValue.filter((item) => item !== value),
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
data-nacos-namespace-multiselect
|
||||
class="flex min-h-9 w-full items-center gap-1.5 rounded-md border border-input bg-background px-2.5 py-1.5 text-left text-sm shadow-sm outline-none transition-colors hover:bg-muted/30 focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<span v-if="!modelValue.length" class="min-w-0 flex-1 truncate text-muted-foreground">{{ t("nacos.namespaceSelectPlaceholder") }}</span>
|
||||
<span v-else class="flex min-w-0 flex-1 flex-wrap gap-1">
|
||||
<span v-for="value in modelValue" :key="value" class="inline-flex max-w-full items-center gap-1 rounded border bg-muted/60 px-1.5 py-0.5 text-xs">
|
||||
<span class="truncate">{{ optionLabels.get(value) || value }}</span>
|
||||
<span
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="shrink-0 rounded-sm text-muted-foreground hover:text-foreground"
|
||||
:aria-label="t('nacos.namespaceRemoveSelection', { namespace: optionLabels.get(value) || value })"
|
||||
@click.stop="remove(value)"
|
||||
@keydown.enter.stop.prevent="remove(value)"
|
||||
@keydown.space.stop.prevent="remove(value)"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<Loader2 v-if="loading" class="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
|
||||
<ChevronDown v-else class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" side="bottom" class="z-[80] w-[var(--reka-popover-trigger-width)] gap-1 p-1.5" @open-auto-focus.prevent>
|
||||
<div class="relative">
|
||||
<Search class="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input v-model="search" class="h-8 pl-8 text-sm" :placeholder="t('nacos.namespaceSearchPlaceholder')" autofocus />
|
||||
</div>
|
||||
<div class="max-h-52 overflow-y-auto py-1">
|
||||
<div v-if="loading" class="flex items-center gap-2 px-2 py-3 text-xs text-muted-foreground"><Loader2 class="h-3.5 w-3.5 animate-spin" />{{ t("nacos.loading") }}</div>
|
||||
<div v-else-if="error" class="space-y-2 px-2 py-2 text-xs text-destructive">
|
||||
<p class="break-words">{{ error }}</p>
|
||||
<button type="button" class="text-foreground underline underline-offset-2" @click="emit('retry')">{{ t("nacos.retryNamespaceList") }}</button>
|
||||
</div>
|
||||
<div v-else-if="!filteredOptions.length" class="px-2 py-3 text-xs text-muted-foreground">{{ t("nacos.noNamespacesAvailable") }}</div>
|
||||
<template v-else>
|
||||
<button v-for="option in filteredOptions" :key="option.value" type="button" class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent focus-visible:bg-accent focus-visible:outline-none" @click="toggle(option.value)">
|
||||
<span class="flex h-4 w-4 shrink-0 items-center justify-center rounded border" :class="selected.has(option.value) ? 'border-primary bg-primary text-primary-foreground' : 'border-input'">
|
||||
<Check v-if="selected.has(option.value)" class="h-3 w-3" />
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate">{{ option.label }}</span>
|
||||
<span v-if="option.label !== option.value" class="max-w-40 truncate text-xs text-muted-foreground">{{ option.value }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="modelValue.length" class="border-t px-2 pt-1.5 text-[11px] text-muted-foreground">{{ t("nacos.namespaceSelectedCount", { count: modelValue.length }) }}</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -0,0 +1,967 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { AlertTriangle, Check, ChevronLeft, ChevronRight, CircleAlert, KeyRound, Link2, Loader2, LockKeyhole, Pencil, Plus, RefreshCw, RotateCcw, Search, Shield, ShieldCheck, Trash2, UserRound, X } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { executeWithProductionContextGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import { mergeNacosNamespacePermissionAssignments, type NacosNamespacePermissionAction } from "@/lib/nacos/nacosAdmin";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import type { NacosAccessControlCapabilities, NacosAccessControlSnapshot, NacosAccessOperationRequest, NacosAccessOperationResult, NacosPermissionDraft, NacosPermissionInfo, NacosUserInfo } from "@/types/nacos";
|
||||
|
||||
const props = defineProps<{
|
||||
connectionId: string;
|
||||
capabilities: NacosAccessControlCapabilities;
|
||||
readOnly?: boolean;
|
||||
tab: "users" | "roles";
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(event: "select-user"): void;
|
||||
(event: "select-role"): void;
|
||||
}>();
|
||||
|
||||
type PermissionAction = NacosNamespacePermissionAction;
|
||||
type PermissionAssignment = { namespaceId: string; action: PermissionAction };
|
||||
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
const snapshot = ref<NacosAccessControlSnapshot | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const search = ref("");
|
||||
const page = ref(1);
|
||||
const pageSize = 30;
|
||||
const selectedUserName = ref("");
|
||||
const selectedRoleName = ref("");
|
||||
|
||||
const userDialog = ref<"create" | "roles" | "password" | "delete" | null>(null);
|
||||
const userForm = reactive({ username: "", password: "", roles: [] as string[], confirmation: "" });
|
||||
const roleDialog = ref<"create" | "edit" | "delete" | null>(null);
|
||||
const roleForm = reactive({
|
||||
role: "",
|
||||
members: [] as string[],
|
||||
newUsers: [] as Array<{ username: string; password: string }>,
|
||||
permissions: [] as PermissionAssignment[],
|
||||
confirmation: "",
|
||||
});
|
||||
const availableNamespaceSearch = ref("");
|
||||
const grantedNamespaceSearch = ref("");
|
||||
const selectedAvailableNamespaceIds = ref<string[]>([]);
|
||||
const selectedGrantedNamespaceIds = ref<string[]>([]);
|
||||
const formError = ref("");
|
||||
const saving = ref(false);
|
||||
const operationOpen = ref(false);
|
||||
const operation = ref<NacosAccessOperationResult | null>(null);
|
||||
const pendingAdvancedPermission = ref<NacosPermissionInfo | null>(null);
|
||||
const advancedPermissionConfirmation = ref("");
|
||||
const retryPasswords = reactive<Record<string, string>>({});
|
||||
|
||||
const users = computed(() => snapshot.value?.users ?? []);
|
||||
const roles = computed(() => snapshot.value?.roles ?? []);
|
||||
const normalizedSearch = computed(() => search.value.trim().toLocaleLowerCase());
|
||||
const filteredUsers = computed(() => users.value.filter((user) => user.username.toLocaleLowerCase().includes(normalizedSearch.value)));
|
||||
const filteredRoles = computed(() => roles.value.filter((role) => role.role.toLocaleLowerCase().includes(normalizedSearch.value)));
|
||||
const currentItems = computed(() => (props.tab === "users" ? filteredUsers.value : filteredRoles.value));
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(currentItems.value.length / pageSize)));
|
||||
const pagedUsers = computed(() => filteredUsers.value.slice((page.value - 1) * pageSize, page.value * pageSize));
|
||||
const pagedRoles = computed(() => filteredRoles.value.slice((page.value - 1) * pageSize, page.value * pageSize));
|
||||
const selectedUser = computed(() => users.value.find((user) => user.username === selectedUserName.value) ?? null);
|
||||
const selectedRole = computed(() => roles.value.find((role) => role.role === selectedRoleName.value) ?? null);
|
||||
const roleMembers = computed(() => (snapshot.value?.roleBindings ?? []).filter((binding) => binding.role === selectedRoleName.value).map((binding) => binding.username));
|
||||
const rolePermissions = computed(() => (snapshot.value?.permissions ?? []).filter((permission) => permission.role === selectedRoleName.value));
|
||||
const managedRolePermissions = computed(() => rolePermissions.value.filter(isManagedPermission));
|
||||
const advancedRolePermissions = computed(() => rolePermissions.value.filter((permission) => !isManagedPermission(permission)));
|
||||
const assignableRoles = computed(() => roles.value.filter((role) => role.administrator || role.permissionCount > 0));
|
||||
const namespaceOptions = computed(() =>
|
||||
(snapshot.value?.namespaces ?? []).map((namespace) => ({
|
||||
id: namespace.namespace || "public",
|
||||
name: namespace.namespaceShowName || namespace.namespace || "public",
|
||||
})),
|
||||
);
|
||||
const grantedNamespaceIds = computed(() => new Set(roleForm.permissions.map((permission) => permission.namespaceId)));
|
||||
const availableNamespaces = computed(() =>
|
||||
filterNamespaces(
|
||||
namespaceOptions.value.filter((namespace) => !grantedNamespaceIds.value.has(namespace.id)),
|
||||
availableNamespaceSearch.value,
|
||||
),
|
||||
);
|
||||
const grantedNamespaces = computed(() =>
|
||||
filterNamespaces(
|
||||
roleForm.permissions.map((permission) => ({
|
||||
...permission,
|
||||
name: namespaceOptions.value.find((namespace) => namespace.id === permission.namespaceId)?.name ?? permission.namespaceId,
|
||||
})),
|
||||
grantedNamespaceSearch.value,
|
||||
),
|
||||
);
|
||||
const selectedUserPermissions = computed(() => effectivePermissions(selectedUser.value));
|
||||
const operationNeedsPasswords = computed(() => operation.value?.steps.filter((step) => ["failed", "skipped"].includes(step.status) && step.needsPassword) ?? []);
|
||||
const adminConfirmationTarget = computed(() => {
|
||||
if (!selectedRole.value?.administrator) return "";
|
||||
const current = snapshot.value?.currentUsername;
|
||||
if (current && roleMembers.value.includes(current) && !roleForm.members.includes(current)) return current;
|
||||
return "ROLE_ADMIN";
|
||||
});
|
||||
const adminMembersChanged = computed(() => {
|
||||
if (!selectedRole.value?.administrator) return false;
|
||||
const before = [...roleMembers.value].sort().join("\n");
|
||||
const after = [...roleForm.members].sort().join("\n");
|
||||
return before !== after || roleForm.newUsers.length > 0;
|
||||
});
|
||||
const canCreateUser = computed(() => !props.readOnly && props.capabilities.createUser.supported);
|
||||
const canUpdateUser = computed(() => !props.readOnly && props.capabilities.updateUser.supported);
|
||||
const canDeleteUser = computed(() => !props.readOnly && props.capabilities.deleteUser.supported);
|
||||
const canAssignRole = computed(() => !props.readOnly && props.capabilities.assignRole.supported);
|
||||
const canRemoveRole = computed(() => !props.readOnly && props.capabilities.removeRole.supported);
|
||||
const canGrantPermission = computed(() => !props.readOnly && props.capabilities.grantPermission.supported);
|
||||
const canRevokePermission = computed(() => !props.readOnly && props.capabilities.revokePermission.supported);
|
||||
const canCreateRole = computed(() => canAssignRole.value && canGrantPermission.value);
|
||||
const canEditUserRoles = computed(() => canAssignRole.value || canRemoveRole.value);
|
||||
const canEditRole = computed(() => canAssignRole.value || canRemoveRole.value || canGrantPermission.value || canRevokePermission.value || canCreateUser.value);
|
||||
|
||||
function setHasAdded(before: Iterable<string>, after: Iterable<string>) {
|
||||
const original = new Set(before);
|
||||
return [...after].some((value) => !original.has(value));
|
||||
}
|
||||
|
||||
function permissionActions(assignments: PermissionAssignment[]) {
|
||||
return new Map(assignments.map((permission) => [permission.namespaceId, permission.action]));
|
||||
}
|
||||
|
||||
const userOperationWritable = computed(() => {
|
||||
if (props.readOnly) return false;
|
||||
if (userDialog.value === "password") return canUpdateUser.value;
|
||||
if (userDialog.value === "create") {
|
||||
return canCreateUser.value && (!userForm.roles.length || canAssignRole.value);
|
||||
}
|
||||
if (userDialog.value === "roles") {
|
||||
const original = selectedUser.value?.roles ?? [];
|
||||
const addsRoles = setHasAdded(original, userForm.roles);
|
||||
const removesRoles = setHasAdded(userForm.roles, original);
|
||||
return (!addsRoles || canAssignRole.value) && (!removesRoles || canRemoveRole.value);
|
||||
}
|
||||
if (userDialog.value === "delete") {
|
||||
return canDeleteUser.value && (!(selectedUser.value?.roles.length ?? 0) || canRemoveRole.value);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const roleOperationWritable = computed(() => {
|
||||
if (props.readOnly) return false;
|
||||
if (roleDialog.value === "create") {
|
||||
return canAssignRole.value && canGrantPermission.value && (!roleForm.newUsers.length || canCreateUser.value);
|
||||
}
|
||||
if (roleDialog.value === "delete") {
|
||||
return (!roleMembers.value.length || canRemoveRole.value) && (!rolePermissions.value.length || canRevokePermission.value);
|
||||
}
|
||||
if (roleDialog.value !== "edit") return false;
|
||||
|
||||
const nextMembers = [...roleForm.members, ...roleForm.newUsers.map((user) => user.username.trim()).filter(Boolean)];
|
||||
const addsMembers = setHasAdded(roleMembers.value, nextMembers);
|
||||
const removesMembers = setHasAdded(nextMembers, roleMembers.value);
|
||||
const beforePermissions = permissionActions(mergeNacosNamespacePermissionAssignments(managedRolePermissions.value));
|
||||
const afterPermissions = permissionActions(roleForm.permissions);
|
||||
let grantsPermissions = false;
|
||||
let revokesPermissions = false;
|
||||
for (const [namespaceId, action] of afterPermissions) {
|
||||
const previous = beforePermissions.get(namespaceId);
|
||||
if (previous !== action) grantsPermissions = true;
|
||||
if (previous && previous !== action) revokesPermissions = true;
|
||||
}
|
||||
for (const namespaceId of beforePermissions.keys()) {
|
||||
if (!afterPermissions.has(namespaceId)) revokesPermissions = true;
|
||||
}
|
||||
return (!roleForm.newUsers.length || canCreateUser.value) && (!addsMembers || canAssignRole.value) && (!removesMembers || canRemoveRole.value) && (!grantsPermissions || canGrantPermission.value) && (!revokesPermissions || canRevokePermission.value);
|
||||
});
|
||||
|
||||
async function loadSnapshot(preserveSelection = true) {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
snapshot.value = await api.nacosAccessSnapshot(props.connectionId);
|
||||
if (!preserveSelection || !users.value.some((user) => user.username === selectedUserName.value)) {
|
||||
selectedUserName.value = users.value[0]?.username ?? "";
|
||||
}
|
||||
if (!preserveSelection || !roles.value.some((role) => role.role === selectedRoleName.value)) {
|
||||
selectedRoleName.value = roles.value[0]?.role ?? "";
|
||||
}
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function isManagedPermission(permission: NacosPermissionInfo) {
|
||||
return permission.parsedScope?.kind === "namespace" && ["r", "w", "rw"].includes(permission.actionRaw);
|
||||
}
|
||||
|
||||
function namespaceName(permission: NacosPermissionInfo) {
|
||||
const id = permission.parsedScope?.namespaceId;
|
||||
if (!id) return permission.resourceRaw;
|
||||
const namespace = namespaceOptions.value.find((item) => item.id === id);
|
||||
return namespace ? namespace.name : `${id} (${t("nacos.accessDeletedNamespace")})`;
|
||||
}
|
||||
|
||||
function actionLabel(action: string) {
|
||||
if (action === "r") return t("nacos.accessReadOnly");
|
||||
if (action === "w") return t("nacos.accessWriteOnly");
|
||||
if (action === "rw") return t("nacos.accessReadWrite");
|
||||
return action;
|
||||
}
|
||||
|
||||
function mergeAction(left: string, right: string) {
|
||||
if (left === right) return left;
|
||||
const read = [left, right].some((value) => value === "r" || value === "rw");
|
||||
const write = [left, right].some((value) => value === "w" || value === "rw");
|
||||
return read && write ? "rw" : read ? "r" : write ? "w" : right;
|
||||
}
|
||||
|
||||
function effectivePermissionKey(permission: NacosPermissionInfo) {
|
||||
if (permission.parsedScope?.kind === "namespace") return `namespace:${permission.parsedScope.namespaceId ?? ""}`;
|
||||
if (permission.resourceRaw === ":*:*" || permission.resourceRaw === "public:*:*") return "namespace:public";
|
||||
return `raw:${permission.resourceRaw}`;
|
||||
}
|
||||
|
||||
function effectivePermissions(user: NacosUserInfo | null) {
|
||||
if (!user || !snapshot.value) return [];
|
||||
if (user.roles.includes("ROLE_ADMIN")) {
|
||||
return [{ resourceRaw: "*:*:*", action: "rw", name: t("nacos.accessAllResources"), sources: ["ROLE_ADMIN"], advanced: false }];
|
||||
}
|
||||
const grouped = new Map<string, { resourceRaw: string; action: string; name: string; sources: string[]; advanced: boolean }>();
|
||||
for (const permission of snapshot.value.permissions.filter((item) => user.roles.includes(item.role))) {
|
||||
const key = effectivePermissionKey(permission);
|
||||
const existing = grouped.get(key);
|
||||
if (existing) {
|
||||
if (existing.resourceRaw === ":*:*" && permission.resourceRaw === "public:*:*") existing.resourceRaw = permission.resourceRaw;
|
||||
existing.action = mergeAction(existing.action, permission.actionRaw);
|
||||
existing.sources.push(permission.role);
|
||||
} else {
|
||||
grouped.set(key, {
|
||||
resourceRaw: permission.resourceRaw,
|
||||
action: permission.actionRaw,
|
||||
name: namespaceName(permission),
|
||||
sources: [permission.role],
|
||||
advanced: !isManagedPermission(permission),
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...grouped.values()].map((item) => ({ ...item, sources: [...new Set(item.sources)].sort() }));
|
||||
}
|
||||
|
||||
async function confirmAccessMutation(reviewText: string) {
|
||||
const confirmed = await executeWithProductionContextGuard({
|
||||
connection: connectionStore.getConfig(props.connectionId),
|
||||
database: "",
|
||||
reviewText,
|
||||
source: t("production.sourceAdmin"),
|
||||
execute: async () => true,
|
||||
});
|
||||
return confirmed === true;
|
||||
}
|
||||
|
||||
function operationReviewText(request: NacosAccessOperationRequest) {
|
||||
switch (request.kind) {
|
||||
case "createUser":
|
||||
return `${t("nacos.createUser")}: ${request.username}`;
|
||||
case "updateUserRoles":
|
||||
return `${t("nacos.accessUserDialog.roles")}: ${request.username}`;
|
||||
case "deleteUser":
|
||||
return `${t("nacos.deleteUser")}: ${request.username}`;
|
||||
case "createRole":
|
||||
return `${t("nacos.accessCreateRole")}: ${request.role}`;
|
||||
case "updateRole":
|
||||
return `${t("nacos.accessRoleDialog.edit")}: ${request.role}`;
|
||||
case "deleteRole":
|
||||
return `${t("nacos.accessDeleteRole")}: ${request.role}`;
|
||||
case "revokePermission":
|
||||
return `${t("nacos.accessRevokeRawPermission")}: ${request.permission.role}`;
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(values: string[], value: string, checked: boolean) {
|
||||
const next = new Set(values);
|
||||
if (checked) next.add(value);
|
||||
else next.delete(value);
|
||||
return [...next];
|
||||
}
|
||||
|
||||
function openCreateUser() {
|
||||
Object.assign(userForm, { username: "", password: "", roles: [], confirmation: "" });
|
||||
formError.value = "";
|
||||
userDialog.value = "create";
|
||||
}
|
||||
|
||||
function openUserRoles() {
|
||||
if (!selectedUser.value) return;
|
||||
Object.assign(userForm, { username: selectedUser.value.username, password: "", roles: [...selectedUser.value.roles], confirmation: "" });
|
||||
formError.value = "";
|
||||
userDialog.value = "roles";
|
||||
}
|
||||
|
||||
function openUserPassword() {
|
||||
if (!selectedUser.value) return;
|
||||
Object.assign(userForm, { username: selectedUser.value.username, password: "", roles: [], confirmation: "" });
|
||||
formError.value = "";
|
||||
userDialog.value = "password";
|
||||
}
|
||||
|
||||
function openDeleteUser() {
|
||||
if (!selectedUser.value) return;
|
||||
Object.assign(userForm, { username: selectedUser.value.username, password: "", roles: [], confirmation: "" });
|
||||
formError.value = "";
|
||||
userDialog.value = "delete";
|
||||
}
|
||||
|
||||
function resetRoleForm(role = "") {
|
||||
Object.assign(roleForm, { role, members: [], newUsers: [], permissions: [], confirmation: "" });
|
||||
availableNamespaceSearch.value = "";
|
||||
grantedNamespaceSearch.value = "";
|
||||
selectedAvailableNamespaceIds.value = [];
|
||||
selectedGrantedNamespaceIds.value = [];
|
||||
}
|
||||
|
||||
function openCreateRole() {
|
||||
resetRoleForm();
|
||||
formError.value = "";
|
||||
roleDialog.value = "create";
|
||||
}
|
||||
|
||||
function openEditRole() {
|
||||
if (!selectedRole.value) return;
|
||||
resetRoleForm(selectedRole.value.role);
|
||||
roleForm.members = [...roleMembers.value];
|
||||
roleForm.permissions = mergeNacosNamespacePermissionAssignments(managedRolePermissions.value);
|
||||
formError.value = "";
|
||||
roleDialog.value = "edit";
|
||||
}
|
||||
|
||||
function openDeleteRole() {
|
||||
if (!selectedRole.value) return;
|
||||
roleForm.confirmation = "";
|
||||
formError.value = "";
|
||||
roleDialog.value = "delete";
|
||||
}
|
||||
|
||||
function openMemberUser(username: string) {
|
||||
selectedUserName.value = username;
|
||||
emit("select-user");
|
||||
}
|
||||
|
||||
function openAssociatedRole(role: string) {
|
||||
selectedRoleName.value = role;
|
||||
emit("select-role");
|
||||
}
|
||||
|
||||
function filterNamespaces<T extends { id?: string; namespaceId?: string; name: string }>(items: T[], query: string) {
|
||||
const normalized = query.trim().toLocaleLowerCase();
|
||||
if (!normalized) return items;
|
||||
return items.filter((item) => `${item.name} ${item.id ?? item.namespaceId ?? ""}`.toLocaleLowerCase().includes(normalized));
|
||||
}
|
||||
|
||||
function moveNamespacesToGranted() {
|
||||
const selected = new Set(selectedAvailableNamespaceIds.value);
|
||||
for (const namespace of namespaceOptions.value) {
|
||||
if (selected.has(namespace.id) && !grantedNamespaceIds.value.has(namespace.id)) {
|
||||
roleForm.permissions.push({ namespaceId: namespace.id, action: "rw" });
|
||||
}
|
||||
}
|
||||
selectedAvailableNamespaceIds.value = [];
|
||||
}
|
||||
|
||||
function removeGrantedNamespaces() {
|
||||
const selected = new Set(selectedGrantedNamespaceIds.value);
|
||||
roleForm.permissions = roleForm.permissions.filter((permission) => !selected.has(permission.namespaceId));
|
||||
selectedGrantedNamespaceIds.value = [];
|
||||
}
|
||||
|
||||
function setPermissionAction(namespaceId: string, action: PermissionAction) {
|
||||
const permission = roleForm.permissions.find((item) => item.namespaceId === namespaceId);
|
||||
if (permission) permission.action = action;
|
||||
}
|
||||
|
||||
function addNewUser() {
|
||||
roleForm.newUsers.push({ username: "", password: "" });
|
||||
}
|
||||
|
||||
async function submitUser() {
|
||||
formError.value = "";
|
||||
if (userDialog.value === "password") {
|
||||
if (!userForm.password) return void (formError.value = t("nacos.accessPasswordRequired"));
|
||||
if (!(await confirmAccessMutation(`${t("nacos.accessUserDialog.password")}: ${userForm.username}`))) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await api.nacosUpdateUser(props.connectionId, { username: userForm.username, password: userForm.password });
|
||||
userDialog.value = null;
|
||||
} catch (cause) {
|
||||
formError.value = cause instanceof Error ? cause.message : String(cause);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
let req: NacosAccessOperationRequest;
|
||||
if (userDialog.value === "create") {
|
||||
if (!userForm.username.trim() || !userForm.password) return void (formError.value = t("nacos.accessCredentialsRequired"));
|
||||
req = { kind: "createUser", username: userForm.username.trim(), password: userForm.password, roles: userForm.roles, confirmation: userForm.confirmation || undefined };
|
||||
} else if (userDialog.value === "roles") {
|
||||
req = { kind: "updateUserRoles", username: userForm.username, roles: userForm.roles, confirmation: userForm.confirmation || undefined };
|
||||
} else {
|
||||
req = { kind: "deleteUser", username: userForm.username, confirmation: userForm.confirmation || undefined };
|
||||
}
|
||||
await runOperation(req, () => (userDialog.value = null));
|
||||
}
|
||||
|
||||
async function submitRole() {
|
||||
formError.value = "";
|
||||
if (roleDialog.value === "delete") {
|
||||
await runOperation({ kind: "deleteRole", role: roleForm.role, confirmation: roleForm.confirmation || undefined }, () => (roleDialog.value = null));
|
||||
return;
|
||||
}
|
||||
if (!roleForm.role.trim() || (!roleForm.members.length && !roleForm.newUsers.length)) {
|
||||
formError.value = t("nacos.accessRoleMemberRequired");
|
||||
return;
|
||||
}
|
||||
const editingAdministrator = roleDialog.value === "edit" && selectedRole.value?.administrator;
|
||||
if (!editingAdministrator && !roleForm.permissions.length) {
|
||||
formError.value = t("nacos.accessRolePermissionRequired");
|
||||
return;
|
||||
}
|
||||
if (roleForm.newUsers.some((user) => !user.username.trim() || !user.password)) {
|
||||
formError.value = t("nacos.accessInlineUserRequired");
|
||||
return;
|
||||
}
|
||||
const permissions: NacosPermissionDraft[] = roleForm.permissions.map((permission) => ({ namespaceIds: [permission.namespaceId], action: permission.action }));
|
||||
const common = {
|
||||
role: roleForm.role.trim(),
|
||||
members: roleForm.members,
|
||||
newUsers: roleForm.newUsers.map((user) => ({ username: user.username.trim(), password: user.password })),
|
||||
permissions,
|
||||
confirmation: roleForm.confirmation || undefined,
|
||||
};
|
||||
const req: NacosAccessOperationRequest = roleDialog.value === "create" ? { kind: "createRole", ...common } : { kind: "updateRole", ...common };
|
||||
await runOperation(req, () => (roleDialog.value = null));
|
||||
}
|
||||
|
||||
async function runOperation(req: NacosAccessOperationRequest, close: () => void) {
|
||||
if (!(await confirmAccessMutation(operationReviewText(req)))) return;
|
||||
saving.value = true;
|
||||
formError.value = "";
|
||||
try {
|
||||
operation.value = await api.nacosStartAccessOperation(props.connectionId, req);
|
||||
close();
|
||||
operationOpen.value = true;
|
||||
await loadSnapshot();
|
||||
} catch (cause) {
|
||||
formError.value = cause instanceof Error ? cause.message : String(cause);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function retryOperation() {
|
||||
if (!operation.value) return;
|
||||
if (!(await confirmAccessMutation(`${t("nacos.accessRetryFailed")}: ${operation.value.operationId}`))) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
operation.value = await api.nacosRetryAccessOperation(props.connectionId, {
|
||||
operationId: operation.value.operationId,
|
||||
credentials: operationNeedsPasswords.value.map((step) => ({ username: step.target, password: retryPasswords[step.target] ?? "" })),
|
||||
});
|
||||
await loadSnapshot();
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function undoOperation() {
|
||||
if (!operation.value) return;
|
||||
if (!(await confirmAccessMutation(`${t("nacos.accessUndoCreated")}: ${operation.value.operationId}`))) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
operation.value = await api.nacosUndoAccessOperation(props.connectionId, operation.value.operationId);
|
||||
await loadSnapshot();
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeAdvancedPermission() {
|
||||
if (!pendingAdvancedPermission.value) return;
|
||||
await runOperation(
|
||||
{
|
||||
kind: "revokePermission",
|
||||
permission: pendingAdvancedPermission.value,
|
||||
confirmation: advancedPermissionConfirmation.value || undefined,
|
||||
},
|
||||
() => (pendingAdvancedPermission.value = null),
|
||||
);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.connectionId,
|
||||
() => void loadSnapshot(false),
|
||||
);
|
||||
watch(
|
||||
() => props.tab,
|
||||
() => {
|
||||
search.value = "";
|
||||
page.value = 1;
|
||||
},
|
||||
);
|
||||
watch(normalizedSearch, () => (page.value = 1));
|
||||
onMounted(() => void loadSnapshot(false));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex min-h-0 flex-1 bg-background">
|
||||
<aside class="flex w-64 shrink-0 flex-col border-r bg-muted/10">
|
||||
<div class="flex h-12 shrink-0 items-center gap-2 border-b px-3">
|
||||
<div class="relative min-w-0 flex-1">
|
||||
<Search class="pointer-events-none absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input v-model="search" class="h-8 pl-8" :placeholder="tab === 'users' ? t('nacos.searchUsers') : t('nacos.accessSearchRoles')" />
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" class="h-8 w-8 p-0" :aria-label="t('nacos.refresh')" :disabled="loading" @click="loadSnapshot()">
|
||||
<RefreshCw class="h-3.5 w-3.5" :class="loading ? 'animate-spin' : ''" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-auto p-2">
|
||||
<template v-if="tab === 'users'">
|
||||
<button v-for="user in pagedUsers" :key="user.username" class="mb-0.5 flex h-10 w-full items-center gap-2 rounded px-2 text-left text-sm hover:bg-accent" :class="selectedUserName === user.username ? 'bg-accent text-accent-foreground' : ''" @click="selectedUserName = user.username">
|
||||
<UserRound class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate font-medium">{{ user.username }}</span>
|
||||
<Badge v-if="user.roles.includes('ROLE_ADMIN')" variant="secondary" class="h-5 px-1.5 text-[10px]">Admin</Badge>
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button v-for="role in pagedRoles" :key="role.role" class="mb-0.5 flex min-h-11 w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-accent" :class="selectedRoleName === role.role ? 'bg-accent text-accent-foreground' : ''" @click="selectedRoleName = role.role">
|
||||
<ShieldCheck class="h-4 w-4 shrink-0" :class="role.complete ? 'text-emerald-600' : 'text-amber-600'" />
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate font-medium">{{ role.role }}</span>
|
||||
<span class="block text-[11px] text-muted-foreground">{{ t("nacos.accessRoleCounts", { members: role.memberCount, permissions: role.permissionCount }) }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
<div v-if="!loading && !currentItems.length" class="flex h-32 items-center justify-center px-4 text-center text-xs text-muted-foreground">{{ tab === "users" ? t("nacos.noUsers") : t("nacos.accessNoRoles") }}</div>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 space-y-2 border-t p-2">
|
||||
<Button class="h-8 w-full" :disabled="tab === 'users' ? !canCreateUser : !canCreateRole" @click="tab === 'users' ? openCreateUser() : openCreateRole()"><Plus class="mr-1.5 h-3.5 w-3.5" />{{ tab === "users" ? t("nacos.createUser") : t("nacos.accessCreateRole") }}</Button>
|
||||
<div class="flex h-7 items-center justify-between px-1 text-xs text-muted-foreground">
|
||||
<span>{{ currentItems.length }}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button size="sm" variant="ghost" class="h-7 w-7 p-0" :disabled="page <= 1" @click="page--"><ChevronLeft class="h-3.5 w-3.5" /></Button><span class="w-10 text-center">{{ page }}/{{ totalPages }}</span
|
||||
><Button size="sm" variant="ghost" class="h-7 w-7 p-0" :disabled="page >= totalPages" @click="page++"><ChevronRight class="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="min-w-0 flex-1 overflow-auto">
|
||||
<div v-if="error" class="m-4 flex items-start gap-2 rounded border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive"><CircleAlert class="mt-0.5 h-4 w-4 shrink-0" />{{ error }}</div>
|
||||
<div v-else-if="loading && !snapshot" class="flex h-full items-center justify-center text-sm text-muted-foreground"><Loader2 class="mr-2 h-4 w-4 animate-spin" />{{ t("nacos.loading") }}</div>
|
||||
|
||||
<template v-else-if="tab === 'users' && selectedUser">
|
||||
<header class="flex min-h-16 items-center gap-3 border-b px-5 py-3">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded border bg-muted/30"><UserRound class="h-4 w-4" /></div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="truncate text-base font-semibold">{{ selectedUser.username }}</h2>
|
||||
<p class="text-xs text-muted-foreground">{{ t("nacos.accessUserSubtitle") }}</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" :disabled="!canUpdateUser" @click="openUserPassword"><KeyRound class="mr-1.5 h-3.5 w-3.5" />{{ t("nacos.resetUserPassword") }}</Button>
|
||||
<Button size="sm" variant="ghost" class="text-destructive hover:text-destructive" :disabled="!canDeleteUser || (!!selectedUser.roles.length && !canRemoveRole)" @click="openDeleteUser"
|
||||
><Trash2 class="h-4 w-4" /><span class="sr-only">{{ t("nacos.deleteUser") }}</span></Button
|
||||
>
|
||||
</header>
|
||||
<div class="mx-auto max-w-5xl px-5 py-5">
|
||||
<section class="mb-7">
|
||||
<div class="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">{{ t("nacos.accessAssociatedRoles") }}</h3>
|
||||
<p class="mt-0.5 text-xs text-muted-foreground">{{ t("nacos.accessAssociatedRolesHint") }}</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<Badge variant="secondary" class="font-normal">{{ selectedUser.roles.length }}</Badge>
|
||||
<Button size="sm" variant="outline" :disabled="!canEditUserRoles" @click="openUserRoles"><Pencil class="mr-1.5 h-3.5 w-3.5" />{{ t("nacos.edit") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedUser.roles.length" class="flex flex-wrap gap-1.5 rounded border bg-muted/20 p-1.5">
|
||||
<button
|
||||
v-for="role in selectedUser.roles"
|
||||
:key="role"
|
||||
type="button"
|
||||
class="group flex h-9 w-52 items-center gap-2 rounded border bg-background px-2 text-left text-sm transition-colors hover:border-primary/40 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:title="t('nacos.accessOpenRole', { role })"
|
||||
@click="openAssociatedRole(role)"
|
||||
>
|
||||
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border bg-muted/30"><Shield class="h-3.5 w-3.5" /></span>
|
||||
<span class="min-w-0 flex-1 truncate font-medium">{{ role }}</span>
|
||||
<Link2 class="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="flex h-20 items-center justify-center rounded border text-sm text-muted-foreground">{{ t("nacos.accessNoAssociatedRoles") }}</div>
|
||||
</section>
|
||||
<section>
|
||||
<div class="mb-3">
|
||||
<h3 class="text-sm font-semibold">{{ t("nacos.accessEffectivePermissions") }}</h3>
|
||||
<p class="mt-0.5 text-xs text-muted-foreground">{{ t("nacos.accessEffectivePermissionsHint") }}</p>
|
||||
</div>
|
||||
<div class="overflow-hidden rounded border">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-muted/40 text-left text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th class="px-3 py-2 font-medium">{{ t("nacos.namespace") }}</th>
|
||||
<th class="w-32 px-3 py-2 font-medium">{{ t("nacos.accessAction") }}</th>
|
||||
<th class="px-3 py-2 font-medium">{{ t("nacos.accessSourceRole") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="permission in selectedUserPermissions" :key="permission.resourceRaw" class="border-t">
|
||||
<td class="px-3 py-2.5">
|
||||
<div class="font-medium">{{ permission.name }}</div>
|
||||
<code v-if="permission.name !== permission.resourceRaw" class="mt-0.5 block text-xs font-normal text-muted-foreground">{{ permission.resourceRaw }}</code>
|
||||
</td>
|
||||
<td class="px-3 py-2.5">
|
||||
<Badge variant="outline" class="font-normal">{{ actionLabel(permission.action) }}</Badge>
|
||||
</td>
|
||||
<td class="px-3 py-2.5">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Badge v-for="source in permission.sources" :key="source" variant="secondary" class="font-normal">{{ source }}</Badge>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="!selectedUserPermissions.length" class="flex h-24 items-center justify-center text-sm text-muted-foreground">{{ t("nacos.accessNoEffectivePermissions") }}</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="tab === 'roles' && selectedRole">
|
||||
<header class="flex min-h-16 items-center gap-3 border-b px-5 py-3">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded border bg-muted/30"><ShieldCheck class="h-4 w-4" /></div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="truncate text-base font-semibold">{{ selectedRole.role }}</h2>
|
||||
<Badge v-if="selectedRole.administrator" variant="secondary">{{ t("nacos.accessGlobalAdministrator") }}</Badge
|
||||
><Badge v-else-if="!selectedRole.complete" variant="outline" class="border-amber-400 text-amber-700">{{ t("nacos.accessNeedsRepair") }}</Badge>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">{{ t("nacos.accessRoleSubtitle", { members: selectedRole.memberCount }) }}</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" :disabled="!canEditRole" @click="openEditRole"><Pencil class="mr-1.5 h-3.5 w-3.5" />{{ t("nacos.edit") }}</Button>
|
||||
<Button v-if="!selectedRole.administrator" size="sm" variant="ghost" class="text-destructive hover:text-destructive" :disabled="readOnly || (!!roleMembers.length && !canRemoveRole) || (!!rolePermissions.length && !canRevokePermission)" @click="openDeleteRole"
|
||||
><Trash2 class="h-4 w-4" /><span class="sr-only">{{ t("nacos.accessDeleteRole") }}</span></Button
|
||||
>
|
||||
</header>
|
||||
<div class="mx-auto max-w-5xl px-5 py-5">
|
||||
<div v-if="!selectedRole.complete" class="mb-5 flex items-start gap-2 rounded border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:bg-amber-950/20 dark:text-amber-200"><AlertTriangle class="mt-0.5 h-4 w-4 shrink-0" />{{ t("nacos.accessIncompleteRoleHint") }}</div>
|
||||
<section class="mb-7">
|
||||
<div class="mb-3">
|
||||
<h3 class="text-sm font-semibold">{{ t("nacos.accessPermissionScope") }}</h3>
|
||||
<p class="mt-0.5 text-xs text-muted-foreground">{{ t("nacos.accessPermissionScopeHint") }}</p>
|
||||
</div>
|
||||
<div v-if="selectedRole.administrator" class="flex items-center gap-3 border-y py-4">
|
||||
<LockKeyhole class="h-5 w-5 text-amber-600" />
|
||||
<div>
|
||||
<div class="text-sm font-medium">{{ t("nacos.accessAllResources") }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ t("nacos.accessAdminBypassHint") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="overflow-hidden rounded border">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-muted/40 text-left text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th class="px-3 py-2 font-medium">{{ t("nacos.namespace") }}</th>
|
||||
<th class="w-36 px-3 py-2 font-medium">{{ t("nacos.accessAction") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="permission in managedRolePermissions" :key="`${permission.resourceRaw}:${permission.actionRaw}`" class="border-t">
|
||||
<td class="px-3 py-2.5">
|
||||
<div class="font-medium">{{ namespaceName(permission) }}</div>
|
||||
<code v-if="namespaceName(permission) !== permission.resourceRaw" class="mt-0.5 block text-xs font-normal text-muted-foreground">{{ permission.resourceRaw }}</code>
|
||||
</td>
|
||||
<td class="px-3 py-2.5">
|
||||
<Badge variant="outline" class="font-normal">{{ actionLabel(permission.actionRaw) }}</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="!managedRolePermissions.length" class="flex h-20 items-center justify-center text-sm text-muted-foreground">{{ t("nacos.accessNoRolePermissions") }}</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="mb-7">
|
||||
<div class="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">{{ t("nacos.accessMembers") }}</h3>
|
||||
<p class="mt-0.5 text-xs text-muted-foreground">{{ t("nacos.accessMembersHint") }}</p>
|
||||
</div>
|
||||
<Badge variant="secondary" class="shrink-0 font-normal">{{ roleMembers.length }}</Badge>
|
||||
</div>
|
||||
<div v-if="roleMembers.length" class="flex flex-wrap gap-1.5 rounded border bg-muted/20 p-1.5">
|
||||
<button
|
||||
v-for="member in roleMembers"
|
||||
:key="member"
|
||||
type="button"
|
||||
class="group flex h-9 w-52 items-center gap-2 rounded border bg-background px-2 text-left text-sm transition-colors hover:border-primary/40 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:title="t('nacos.accessOpenUser', { username: member })"
|
||||
@click="openMemberUser(member)"
|
||||
>
|
||||
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border bg-muted/30"><UserRound class="h-3.5 w-3.5" /></span>
|
||||
<span class="min-w-0 flex-1 truncate font-medium">{{ member }}</span>
|
||||
<Link2 class="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="flex h-20 items-center justify-center rounded border text-sm text-muted-foreground">{{ t("nacos.accessNoMembers") }}</div>
|
||||
</section>
|
||||
<section v-if="advancedRolePermissions.length">
|
||||
<div class="mb-3">
|
||||
<h3 class="text-sm font-semibold">{{ t("nacos.accessAdvancedPermissions") }}</h3>
|
||||
<p class="mt-0.5 text-xs text-muted-foreground">{{ t("nacos.accessAdvancedPermissionsHint") }}</p>
|
||||
</div>
|
||||
<div class="divide-y rounded border">
|
||||
<div v-for="permission in advancedRolePermissions" :key="`${permission.resourceRaw}:${permission.actionRaw}`" class="flex items-center gap-3 px-3 py-2.5 text-sm">
|
||||
<code class="min-w-0 flex-1 truncate">{{ permission.resourceRaw }}</code
|
||||
><Badge variant="outline">{{ permission.actionRaw }}</Badge
|
||||
><Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="h-7 w-7 p-0 text-destructive hover:text-destructive"
|
||||
:disabled="!canRevokePermission"
|
||||
:aria-label="t('nacos.accessRevokeRawPermission')"
|
||||
@click="
|
||||
pendingAdvancedPermission = permission;
|
||||
advancedPermissionConfirmation = '';
|
||||
"
|
||||
><Trash2 class="h-3.5 w-3.5"
|
||||
/></Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else-if="!loading" class="flex h-full items-center justify-center text-sm text-muted-foreground">{{ t("nacos.accessSelectItem") }}</div>
|
||||
</main>
|
||||
</section>
|
||||
|
||||
<Dialog :open="!!userDialog" @update:open="!$event && (userDialog = null)">
|
||||
<DialogContent class="max-h-[88vh] overflow-auto sm:max-w-lg">
|
||||
<DialogHeader
|
||||
><DialogTitle>{{ t(`nacos.accessUserDialog.${userDialog}`) }}</DialogTitle
|
||||
><DialogDescription>{{ t(`nacos.accessUserDialog.${userDialog}Hint`, { username: userForm.username }) }}</DialogDescription></DialogHeader
|
||||
>
|
||||
<div v-if="userDialog === 'create' || userDialog === 'password'" class="space-y-4">
|
||||
<div v-if="userDialog === 'create'" class="grid gap-1.5">
|
||||
<Label for="access-user-name">{{ t("nacos.username") }}</Label
|
||||
><Input id="access-user-name" v-model="userForm.username" autocomplete="off" />
|
||||
</div>
|
||||
<div class="grid gap-1.5">
|
||||
<Label for="access-user-password">{{ t("nacos.password") }}</Label
|
||||
><Input id="access-user-password" v-model="userForm.password" type="password" autocomplete="new-password" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="userDialog === 'create' || userDialog === 'roles'" class="space-y-2">
|
||||
<Label>{{ t("nacos.accessAssociatedRoles") }}</Label>
|
||||
<div class="max-h-52 overflow-auto rounded border p-2">
|
||||
<label v-for="role in assignableRoles" :key="role.role" class="flex min-h-9 items-center gap-2 rounded px-2 text-sm hover:bg-muted/60"
|
||||
><input
|
||||
type="checkbox"
|
||||
:checked="userForm.roles.includes(role.role)"
|
||||
:disabled="userDialog === 'roles' && (selectedUser?.roles.includes(role.role) ?? false) ? !canRemoveRole : !canAssignRole"
|
||||
@change="userForm.roles = toggle(userForm.roles, role.role, ($event.target as HTMLInputElement).checked)"
|
||||
/><Shield class="h-3.5 w-3.5 text-muted-foreground" /><span class="min-w-0 flex-1 truncate">{{ role.role }}</span
|
||||
><span v-if="!role.complete" class="text-xs text-amber-600">{{ t("nacos.accessNeedsRepair") }}</span></label
|
||||
>
|
||||
<div v-if="!assignableRoles.length" class="p-3 text-center text-xs text-muted-foreground">{{ t("nacos.accessNoAssignableRoles") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="userDialog === 'delete'" class="rounded border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">{{ t("nacos.deleteUserDescription", { username: userForm.username }) }}</div>
|
||||
<div v-if="userForm.roles.includes('ROLE_ADMIN') || (snapshot?.currentUsername === userForm.username && userDialog !== 'password')" class="grid gap-1.5">
|
||||
<Label for="access-user-confirm">{{ t("nacos.accessTypeUsername", { value: userForm.username }) }}</Label
|
||||
><Input id="access-user-confirm" v-model="userForm.confirmation" autocomplete="off" />
|
||||
</div>
|
||||
<p v-if="formError" class="text-sm text-destructive">{{ formError }}</p>
|
||||
<DialogFooter
|
||||
><Button variant="outline" :disabled="saving" @click="userDialog = null">{{ t("nacos.cancel") }}</Button
|
||||
><Button :variant="userDialog === 'delete' ? 'destructive' : 'default'" :disabled="saving || !userOperationWritable" @click="submitUser"><Loader2 v-if="saving" class="mr-2 h-4 w-4 animate-spin" />{{ userDialog === "delete" ? t("nacos.delete") : t("nacos.save") }}</Button></DialogFooter
|
||||
>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog :open="!!roleDialog" @update:open="!$event && (roleDialog = null)">
|
||||
<DialogContent class="max-h-[90vh] overflow-auto sm:max-w-2xl">
|
||||
<DialogHeader
|
||||
><DialogTitle>{{ t(`nacos.accessRoleDialog.${roleDialog}`) }}</DialogTitle
|
||||
><DialogDescription>{{ t(`nacos.accessRoleDialog.${roleDialog}Hint`) }}</DialogDescription></DialogHeader
|
||||
>
|
||||
<template v-if="roleDialog !== 'delete'">
|
||||
<div class="space-y-5">
|
||||
<div class="grid gap-1.5">
|
||||
<Label for="access-role-name">{{ t("nacos.role") }}</Label
|
||||
><Input id="access-role-name" v-model="roleForm.role" :disabled="roleDialog === 'edit'" placeholder="app_developer" />
|
||||
</div>
|
||||
<section>
|
||||
<div class="mb-2 flex items-end justify-between">
|
||||
<div>
|
||||
<Label>{{ t("nacos.accessMembers") }}</Label>
|
||||
<p class="mt-0.5 text-xs text-muted-foreground">{{ t("nacos.accessMembersRequiredHint") }}</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" :disabled="!canCreateUser || !canAssignRole" @click="addNewUser"><Plus class="mr-1 h-3.5 w-3.5" />{{ t("nacos.accessInlineCreateUser") }}</Button>
|
||||
</div>
|
||||
<div class="max-h-44 overflow-auto rounded border p-2">
|
||||
<label v-for="user in users" :key="user.username" class="flex min-h-9 items-center gap-2 rounded px-2 text-sm hover:bg-muted/60"
|
||||
><input
|
||||
type="checkbox"
|
||||
:checked="roleForm.members.includes(user.username)"
|
||||
:disabled="roleDialog === 'edit' && roleMembers.includes(user.username) ? !canRemoveRole : !canAssignRole"
|
||||
@change="roleForm.members = toggle(roleForm.members, user.username, ($event.target as HTMLInputElement).checked)"
|
||||
/><UserRound class="h-3.5 w-3.5 text-muted-foreground" />{{ user.username }}</label
|
||||
>
|
||||
</div>
|
||||
<div v-for="(user, index) in roleForm.newUsers" :key="index" class="mt-2 grid grid-cols-[1fr_1fr_32px] gap-2">
|
||||
<Input v-model="user.username" :placeholder="t('nacos.username')" /><Input v-model="user.password" type="password" :placeholder="t('nacos.password')" /><Button size="sm" variant="ghost" class="h-9 w-8 p-0" @click="roleForm.newUsers.splice(index, 1)"><X class="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="selectedRole?.administrator && roleDialog === 'edit'" class="rounded border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:bg-amber-950/20 dark:text-amber-200">
|
||||
<div class="flex gap-2"><LockKeyhole class="mt-0.5 h-4 w-4 shrink-0" />{{ t("nacos.accessAdminPermissionImmutable") }}</div>
|
||||
</section>
|
||||
<section v-else>
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<Label>{{ t("nacos.accessPermissionScope") }}</Label>
|
||||
<p class="mt-0.5 text-xs text-muted-foreground">{{ t("nacos.accessPermissionRequiredHint") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid min-h-[17rem] grid-cols-[minmax(0,1fr)_2.5rem_minmax(0,1.15fr)] gap-2 sm:gap-3">
|
||||
<div class="flex min-h-0 flex-col overflow-hidden rounded border">
|
||||
<div class="border-b bg-muted/30 px-3 py-2 text-sm font-medium">{{ t("nacos.accessUnassignedNamespaces") }}</div>
|
||||
<div class="border-b p-2">
|
||||
<div class="relative"><Search class="pointer-events-none absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" /><Input v-model="availableNamespaceSearch" class="h-8 pl-8 text-sm" :placeholder="t('nacos.accessSearchNamespaces')" /></div>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto p-1.5">
|
||||
<label v-for="namespace in availableNamespaces" :key="namespace.id" class="flex min-h-9 items-center gap-2 rounded px-2 text-sm hover:bg-muted/60">
|
||||
<input type="checkbox" :checked="selectedAvailableNamespaceIds.includes(namespace.id)" @change="selectedAvailableNamespaceIds = toggle(selectedAvailableNamespaceIds, namespace.id, ($event.target as HTMLInputElement).checked)" />
|
||||
<span class="min-w-0 flex-1 truncate">{{ namespace.name }}</span>
|
||||
<code v-if="namespace.name !== namespace.id" class="truncate text-[10px] text-muted-foreground">{{ namespace.id }}</code>
|
||||
</label>
|
||||
<div v-if="!availableNamespaces.length" class="flex h-24 items-center justify-center px-3 text-center text-xs text-muted-foreground">{{ t("nacos.accessNoUnassignedNamespaces") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center gap-2">
|
||||
<Button size="sm" variant="outline" class="h-8 w-8 p-0" :disabled="!selectedAvailableNamespaceIds.length || !canGrantPermission" :aria-label="t('nacos.accessGrantNamespaces')" @click="moveNamespacesToGranted"><ChevronRight class="h-4 w-4" /></Button>
|
||||
<Button size="sm" variant="outline" class="h-8 w-8 p-0" :disabled="!selectedGrantedNamespaceIds.length || !canRevokePermission" :aria-label="t('nacos.accessRevokeNamespaces')" @click="removeGrantedNamespaces"><ChevronLeft class="h-4 w-4" /></Button>
|
||||
</div>
|
||||
<div class="flex min-h-0 flex-col overflow-hidden rounded border">
|
||||
<div class="border-b bg-muted/30 px-3 py-2 text-sm font-medium">{{ t("nacos.accessGrantedNamespaces") }}</div>
|
||||
<div class="border-b p-2">
|
||||
<div class="relative"><Search class="pointer-events-none absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" /><Input v-model="grantedNamespaceSearch" class="h-8 pl-8 text-sm" :placeholder="t('nacos.accessSearchNamespaces')" /></div>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto p-1.5">
|
||||
<div v-for="namespace in grantedNamespaces" :key="namespace.namespaceId" class="flex min-h-10 items-center gap-2 rounded px-2 text-sm hover:bg-muted/60">
|
||||
<input type="checkbox" :checked="selectedGrantedNamespaceIds.includes(namespace.namespaceId)" @change="selectedGrantedNamespaceIds = toggle(selectedGrantedNamespaceIds, namespace.namespaceId, ($event.target as HTMLInputElement).checked)" />
|
||||
<span class="min-w-0 flex-1 truncate font-medium">{{ namespace.name }}</span>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<label
|
||||
v-for="action in ['r', 'w', 'rw'] as PermissionAction[]"
|
||||
:key="action"
|
||||
class="flex h-7 cursor-pointer items-center gap-1.5 rounded border px-2 text-xs transition-colors"
|
||||
:class="namespace.action === action ? 'border-primary/40 bg-primary/5 font-medium text-foreground' : 'border-transparent text-muted-foreground hover:bg-muted'"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-3.5 w-3.5 accent-primary"
|
||||
:checked="namespace.action === action"
|
||||
:disabled="!canGrantPermission || (roleDialog === 'edit' && managedRolePermissions.some((permission) => permission.parsedScope?.namespaceId === namespace.namespaceId) && !canRevokePermission)"
|
||||
@change="setPermissionAction(namespace.namespaceId, action)"
|
||||
/>
|
||||
{{ actionLabel(action) }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!grantedNamespaces.length" class="flex h-24 items-center justify-center px-3 text-center text-xs text-muted-foreground">{{ t("nacos.accessNoGrantedNamespaces") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div v-if="roleDialog === 'edit' && adminMembersChanged" class="grid gap-1.5">
|
||||
<Label for="access-role-confirm">{{ t("nacos.accessTypeUsername", { value: adminConfirmationTarget }) }}</Label
|
||||
><Input id="access-role-confirm" v-model="roleForm.confirmation" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else
|
||||
><div class="rounded border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">{{ t("nacos.accessDeleteRoleHint", { role: roleForm.role }) }}</div>
|
||||
<div class="grid gap-1.5">
|
||||
<Label for="access-delete-role-confirm">{{ t("nacos.accessTypeRole", { value: roleForm.role }) }}</Label
|
||||
><Input id="access-delete-role-confirm" v-model="roleForm.confirmation" /></div
|
||||
></template>
|
||||
<p v-if="formError" class="text-sm text-destructive">{{ formError }}</p>
|
||||
<DialogFooter
|
||||
><Button variant="outline" :disabled="saving" @click="roleDialog = null">{{ t("nacos.cancel") }}</Button
|
||||
><Button :variant="roleDialog === 'delete' ? 'destructive' : 'default'" :disabled="saving || !roleOperationWritable" @click="submitRole"><Loader2 v-if="saving" class="mr-2 h-4 w-4 animate-spin" />{{ roleDialog === "delete" ? t("nacos.delete") : t("nacos.save") }}</Button></DialogFooter
|
||||
>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:open="operationOpen">
|
||||
<DialogContent class="sm:max-w-xl"
|
||||
><DialogHeader
|
||||
><DialogTitle>{{ t("nacos.accessOperationResult") }}</DialogTitle
|
||||
><DialogDescription>{{ t(`nacos.accessOperationStatus.${operation?.status ?? "running"}`) }}</DialogDescription></DialogHeader
|
||||
>
|
||||
<div class="max-h-80 divide-y overflow-auto rounded border">
|
||||
<div v-for="step in operation?.steps" :key="step.id" class="flex items-start gap-3 px-3 py-2.5">
|
||||
<Check v-if="step.status === 'succeeded' || step.status === 'compensated'" class="mt-0.5 h-4 w-4 shrink-0 text-emerald-600" /><CircleAlert v-else-if="step.status === 'failed'" class="mt-0.5 h-4 w-4 shrink-0 text-destructive" /><Loader2
|
||||
v-else-if="step.status === 'running'"
|
||||
class="mt-0.5 h-4 w-4 shrink-0 animate-spin"
|
||||
/>
|
||||
<div v-else class="mt-1 h-3 w-3 shrink-0 rounded-full border" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-medium">{{ t(`nacos.accessStep.${step.action}`) }}</div>
|
||||
<div class="truncate text-xs text-muted-foreground">{{ step.target }}</div>
|
||||
<div v-if="step.message" class="mt-1 text-xs text-destructive">{{ step.message }}</div>
|
||||
<Input v-if="['failed', 'skipped'].includes(step.status) && step.needsPassword" v-model="retryPasswords[step.target]" type="password" class="mt-2 h-8" :placeholder="t('nacos.accessReenterPassword')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter
|
||||
><Button v-if="operation?.canUndo" variant="outline" :disabled="saving" @click="undoOperation"><RotateCcw class="mr-1.5 h-3.5 w-3.5" />{{ t("nacos.accessUndoCreated") }}</Button
|
||||
><Button v-if="operation?.canRetry" :disabled="saving" @click="retryOperation"><RefreshCw class="mr-1.5 h-3.5 w-3.5" />{{ t("nacos.accessRetryFailed") }}</Button
|
||||
><Button v-else @click="operationOpen = false">{{ t("nacos.close") }}</Button></DialogFooter
|
||||
></DialogContent
|
||||
>
|
||||
</Dialog>
|
||||
|
||||
<Dialog :open="!!pendingAdvancedPermission" @update:open="!$event && (pendingAdvancedPermission = null)">
|
||||
<DialogContent class="sm:max-w-md"
|
||||
><DialogHeader
|
||||
><DialogTitle>{{ t("nacos.accessRevokeRawPermission") }}</DialogTitle
|
||||
><DialogDescription>{{ t("nacos.accessRevokeRawPermissionHint") }}</DialogDescription></DialogHeader
|
||||
>
|
||||
<div class="rounded border bg-muted/30 p-3 text-sm">
|
||||
<code class="block break-all">{{ pendingAdvancedPermission?.resourceRaw }}</code>
|
||||
<div class="mt-2 text-xs text-muted-foreground">{{ pendingAdvancedPermission?.role }} · {{ pendingAdvancedPermission?.actionRaw }}</div>
|
||||
</div>
|
||||
<div class="grid gap-1.5">
|
||||
<Label for="access-raw-confirm">{{ t("nacos.accessTypeRole", { value: pendingAdvancedPermission?.role }) }}</Label
|
||||
><Input id="access-raw-confirm" v-model="advancedPermissionConfirmation" />
|
||||
</div>
|
||||
<p v-if="formError" class="text-sm text-destructive">{{ formError }}</p>
|
||||
<DialogFooter
|
||||
><Button variant="outline" :disabled="saving" @click="pendingAdvancedPermission = null">{{ t("nacos.cancel") }}</Button
|
||||
><Button variant="destructive" :disabled="saving || !canRevokePermission" @click="revokeAdvancedPermission"><Loader2 v-if="saving" class="mr-2 h-4 w-4 animate-spin" />{{ t("nacos.accessRevokeRawPermission") }}</Button></DialogFooter
|
||||
></DialogContent
|
||||
>
|
||||
</Dialog>
|
||||
</template>
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
/** @vitest-environment happy-dom */
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { createApp, nextTick, type App } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { NacosConnectionInfo } from "@/types/nacos";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
connectionInfo: null as NacosConnectionInfo | null,
|
||||
ensureConnected: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
nacosTestConnection: vi.fn(async () => {
|
||||
if (!mocks.connectionInfo) throw new Error("missing Nacos connection fixture");
|
||||
return mocks.connectionInfo;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => ({ ensureConnected: mocks.ensureConnected }),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/nacos/NacosAccessControl.vue", async () => {
|
||||
const { h } = await import("vue");
|
||||
return {
|
||||
default: {
|
||||
props: ["tab"],
|
||||
setup: (props: { tab: string }) => () => h("div", { "data-testid": "nacos-access-control-workspace", "data-tab": props.tab }),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/nacos/NacosRoleAccessControl.vue", async () => {
|
||||
const { h } = await import("vue");
|
||||
return {
|
||||
default: {
|
||||
props: ["capabilities"],
|
||||
setup: (props: { capabilities: { createUser: { supported: boolean } } }) => () =>
|
||||
h("div", {
|
||||
"data-testid": "nacos-role-access-control-workspace",
|
||||
"data-can-create-user": String(props.capabilities.createUser.supported),
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import NacosAccessControlConsole from "../NacosAccessControlConsole.vue";
|
||||
|
||||
const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/nacos/NacosAccessControlConsole.vue"), "utf8");
|
||||
const legacySource = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/nacos/NacosAccessControl.vue"), "utf8");
|
||||
const treeItemSource = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/sidebar/TreeItem.vue"), "utf8");
|
||||
const connectionTreeSource = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/sidebar/ConnectionTree.vue"), "utf8");
|
||||
const connectionStoreSource = readFileSync(resolve(process.cwd(), "apps/desktop/src/stores/connectionStore.ts"), "utf8");
|
||||
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
|
||||
|
||||
async function settle() {
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, host } of mountedApps.splice(0)) {
|
||||
app.unmount();
|
||||
host.remove();
|
||||
}
|
||||
mocks.connectionInfo = null;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("NacosAccessControlConsole", () => {
|
||||
it("uses the access-control header pattern for its navigation", () => {
|
||||
expect(source).toContain('class="flex h-14 shrink-0 flex-wrap items-center gap-3 border-b px-4"');
|
||||
expect(source).toContain('class="flex rounded-md border p-0.5 shadow-sm"');
|
||||
expect(source).toContain("<UsersRound");
|
||||
expect(source).toContain("<KeyRound");
|
||||
expect(source).toContain('t("nacos.accessControlTitle")');
|
||||
expect(source).toContain('t("nacos.accessControlDescription")');
|
||||
});
|
||||
|
||||
it("only submits r-nacos namespace privilege fields supported by its API", () => {
|
||||
expect(legacySource).not.toContain("privilegeEnabled");
|
||||
expect(legacySource).toContain("whitelistIsAll: userForm.value.whitelistIsAll");
|
||||
expect(legacySource).toContain("blacklistIsAll: userForm.value.blacklistIsAll");
|
||||
expect(legacySource).toContain('import NacosNamespaceMultiSelect from "@/components/nacos/NacosNamespaceMultiSelect.vue"');
|
||||
expect(legacySource).toContain('v-model="userForm.whitelist"');
|
||||
expect(legacySource).toContain('v-model="userForm.blacklist"');
|
||||
expect(legacySource).toContain("whitelist: [...userForm.value.whitelist]");
|
||||
expect(legacySource).toContain("blacklist: [...userForm.value.blacklist]");
|
||||
expect(legacySource).not.toContain('<textarea v-model="userForm.whitelist"');
|
||||
expect(legacySource).not.toContain('<textarea v-model="userForm.blacklist"');
|
||||
});
|
||||
|
||||
it("switches tabs when navigating between associated users and roles", () => {
|
||||
expect(source).toContain("@select-user=\"activeTab = 'users'\"");
|
||||
expect(source).toContain("@select-role=\"activeTab = 'roles'\"");
|
||||
});
|
||||
|
||||
it("keeps the roles-only workspace reachable without an unavailable banner", () => {
|
||||
expect(source).toContain('if (!supportsUsers.value && supportsRoles.value) activeTab.value = "roles"');
|
||||
expect(source).toContain('v-if="!supportsUsers && !supportsRoles"');
|
||||
expect(connectionStoreSource).toContain("sidebarSnapshot.accessControl.listUsers.supported === true ||");
|
||||
expect(connectionStoreSource).toContain("sidebarSnapshot.accessControl.listRoleBindings.supported === true");
|
||||
});
|
||||
|
||||
it("keeps the roles-only workspace visible alongside the permission warning", async () => {
|
||||
const unsupported = { supported: false, reason: "permissionDenied" as const };
|
||||
mocks.connectionInfo = {
|
||||
serverAddr: "http://127.0.0.1:8848",
|
||||
displayServerAddr: "http://127.0.0.1:8848",
|
||||
namespace: "",
|
||||
auth: "usernamePassword",
|
||||
capabilities: {
|
||||
supportsConfigManagement: true,
|
||||
supportsServiceManagement: true,
|
||||
supportsInstanceUpdate: true,
|
||||
supportsRawApi: true,
|
||||
accessControl: {
|
||||
mode: "roleBindings",
|
||||
listUsers: unsupported,
|
||||
createUser: unsupported,
|
||||
updateUser: unsupported,
|
||||
deleteUser: unsupported,
|
||||
listRoleBindings: { supported: true },
|
||||
assignRole: unsupported,
|
||||
removeRole: unsupported,
|
||||
listPermissions: unsupported,
|
||||
grantPermission: unsupported,
|
||||
revokePermission: unsupported,
|
||||
enhancedWorkspace: false,
|
||||
supportsNamespacePrivileges: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const app = createApp(NacosAccessControlConsole, { connectionId: "nacos-1" });
|
||||
mountedApps.push({ app, host });
|
||||
app.mount(host);
|
||||
await settle();
|
||||
|
||||
expect(host.textContent).toContain("nacos.accessPermissionEndpointUnavailable");
|
||||
const workspace = host.querySelector('[data-testid="nacos-access-control-workspace"]');
|
||||
expect(workspace).not.toBeNull();
|
||||
expect(workspace?.getAttribute("data-tab")).toBe("roles");
|
||||
});
|
||||
|
||||
it("shares the directory-detail workspace across supported Nacos versions", () => {
|
||||
expect(source).toContain('import NacosRoleAccessControl from "@/components/nacos/NacosRoleAccessControl.vue"');
|
||||
expect(source).toContain('<NacosRoleAccessControl v-if="enhancedWorkspace && accessControl"');
|
||||
expect(source).toContain(':capabilities="accessControl"');
|
||||
});
|
||||
|
||||
it("keeps the enhanced read workspace visible when writes are permission denied", async () => {
|
||||
const unsupported = { supported: false, reason: "permissionDenied" as const };
|
||||
const supported = { supported: true };
|
||||
mocks.connectionInfo = {
|
||||
serverAddr: "http://127.0.0.1:8848",
|
||||
displayServerAddr: "http://127.0.0.1:8848",
|
||||
namespace: "",
|
||||
auth: "usernamePassword",
|
||||
capabilities: {
|
||||
supportsConfigManagement: true,
|
||||
supportsServiceManagement: true,
|
||||
supportsInstanceUpdate: true,
|
||||
supportsRawApi: true,
|
||||
accessControl: {
|
||||
mode: "roleBindings",
|
||||
listUsers: supported,
|
||||
createUser: unsupported,
|
||||
updateUser: unsupported,
|
||||
deleteUser: unsupported,
|
||||
listRoleBindings: supported,
|
||||
assignRole: unsupported,
|
||||
removeRole: unsupported,
|
||||
listPermissions: supported,
|
||||
grantPermission: unsupported,
|
||||
revokePermission: unsupported,
|
||||
enhancedWorkspace: true,
|
||||
supportsNamespacePrivileges: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const app = createApp(NacosAccessControlConsole, { connectionId: "nacos-read-only" });
|
||||
mountedApps.push({ app, host });
|
||||
app.mount(host);
|
||||
await settle();
|
||||
|
||||
const workspace = host.querySelector('[data-testid="nacos-role-access-control-workspace"]');
|
||||
expect(workspace).not.toBeNull();
|
||||
expect(workspace?.getAttribute("data-can-create-user")).toBe("false");
|
||||
});
|
||||
|
||||
it("internationalizes the sidebar entry, including trees persisted with the old label", () => {
|
||||
expect(connectionStoreSource).toContain('label: "nacos.accessControlSidebarLabel"');
|
||||
expect(treeItemSource).toContain('if (node.type === "nacos-access-control") return t("nacos.accessControlSidebarLabel")');
|
||||
expect(connectionTreeSource).toContain('node.type === "nacos-access-control" ? t("nacos.accessControlSidebarLabel") : node.label');
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,11 @@ import { describe, expect, it } from "vitest";
|
||||
const source = readFileSync(new URL("../NacosAdminConsole.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("NacosAdminConsole config workbench layout", () => {
|
||||
it("keeps access control outside the namespace-scoped workbench", () => {
|
||||
expect(source).not.toContain("NacosAccessControl");
|
||||
expect(source).toContain('type AdminTab = "configs" | "services";');
|
||||
});
|
||||
|
||||
it("keeps the editor as the final and primary workbench surface", () => {
|
||||
const contextBar = source.indexOf('class="nacos-config-context-bar');
|
||||
const inspector = source.indexOf('class="nacos-config-inspector');
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = readFileSync(new URL("../NacosNamespaceMultiSelect.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("NacosNamespaceMultiSelect", () => {
|
||||
it("renders selected namespaces as removable tags and keeps the dropdown open for multi-selection", () => {
|
||||
expect(source).toContain("data-nacos-namespace-multiselect");
|
||||
expect(source).toContain('v-for="value in modelValue"');
|
||||
expect(source).toContain('@click.stop="remove(value)"');
|
||||
expect(source).toContain('@click="toggle(option.value)"');
|
||||
expect(source).toContain("selected.has(option.value)");
|
||||
expect(source).toContain("nacos.namespaceSearchPlaceholder");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = readFileSync(new URL("../NacosRoleAccessControl.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("NacosRoleAccessControl", () => {
|
||||
it("uses a directory-detail layout for both users and roles", () => {
|
||||
expect(source).toContain('class="flex w-64 shrink-0 flex-col border-r');
|
||||
expect(source).toContain("v-if=\"tab === 'users'\"");
|
||||
expect(source).toContain("v-else-if=\"tab === 'roles' && selectedRole\"");
|
||||
});
|
||||
|
||||
it("keeps role creation complete with per-namespace permissions", () => {
|
||||
expect(source).toContain("roleForm.newUsers");
|
||||
expect(source).toContain("moveNamespacesToGranted");
|
||||
expect(source).toContain('action: "rw"');
|
||||
expect(source).toContain("setPermissionAction");
|
||||
expect(source).toContain("accessUnassignedNamespaces");
|
||||
expect(source).toContain("accessGrantedNamespaces");
|
||||
expect(source).toContain('kind: "createRole"');
|
||||
});
|
||||
|
||||
it("merges split permission rows before editing an existing role", () => {
|
||||
expect(source).toContain("mergeNacosNamespacePermissionAssignments(managedRolePermissions.value)");
|
||||
});
|
||||
|
||||
it("gates every mutation by its probed operation capability", () => {
|
||||
expect(source).toContain("capabilities: NacosAccessControlCapabilities");
|
||||
expect(source).toContain("const canCreateUser = computed");
|
||||
expect(source).toContain("const canGrantPermission = computed");
|
||||
expect(source).toContain("const userOperationWritable = computed");
|
||||
expect(source).toContain("const roleOperationWritable = computed");
|
||||
expect(source).toContain(':disabled="saving || !userOperationWritable"');
|
||||
expect(source).toContain(':disabled="saving || !roleOperationWritable"');
|
||||
});
|
||||
|
||||
it("surfaces partial operations instead of hiding them in a toast", () => {
|
||||
expect(source).toContain("operationNeedsPasswords");
|
||||
expect(source).toContain("nacosRetryAccessOperation");
|
||||
expect(source).toContain("nacosUndoAccessOperation");
|
||||
expect(source).toContain("accessOperationResult");
|
||||
});
|
||||
|
||||
it("renders associated roles with the same navigable card pattern as role members", () => {
|
||||
expect(source).toContain('v-if="selectedUser.roles.length" class="flex flex-wrap gap-1.5 rounded border bg-muted/20 p-1.5"');
|
||||
expect(source).toContain(":title=\"t('nacos.accessOpenRole', { role })\"");
|
||||
expect(source).toContain('@click="openAssociatedRole(role)"');
|
||||
expect(source).toContain('emit("select-role")');
|
||||
});
|
||||
});
|
||||
@@ -626,7 +626,10 @@ const filteredNodes = computed(() => {
|
||||
nodes = filterGloballyIndexedRegexTables(nodes);
|
||||
|
||||
const q = deferredSearchQuery.value;
|
||||
nodes = filterSidebarTree(nodes, q, searchCollapsedIds.value, searchableNodeTypes.value, { regexMode: regexMode.value });
|
||||
nodes = filterSidebarTree(nodes, q, searchCollapsedIds.value, searchableNodeTypes.value, {
|
||||
regexMode: regexMode.value,
|
||||
resolveLabel: (node) => (node.type === "nacos-access-control" ? t("nacos.accessControlSidebarLabel") : node.label),
|
||||
});
|
||||
if (q && !regexMode.value) {
|
||||
nodes = filterSidebarSearchRootsByConnectionState(nodes, store.connectedIds);
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ import {
|
||||
usesTreeSchemaMode,
|
||||
isSingleDatabase,
|
||||
} from "@/lib/database/databaseCapabilities";
|
||||
import { copyDisplayPathForTreeNode, copyNameForTreeNode, isDirectNavigationTreeNode, isDocumentBrowserTreeNode, objectSourceTargetForTreeNode, shouldRunTreeNodeRowAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/sidebar/treeNodeClick";
|
||||
import { copyDisplayPathForTreeNode, copyNameForTreeNode, isDirectNavigationTreeNode, isDocumentBrowserTreeNode, isRepeatableNavigationTreeNode, objectSourceTargetForTreeNode, shouldRunTreeNodeRowAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/sidebar/treeNodeClick";
|
||||
import { customTypeCapabilities, supportsTypeObjectSource } from "@/lib/database/databaseObjectCapabilities";
|
||||
import { mongoCollectionTableTypeFromNode, mongoDropIndexFailureCount } from "@/lib/sidebar/mongoCollectionMutation";
|
||||
import { dataTabOpenModeFromTreeClick, type DataTabOpenMode } from "@/lib/sidebar/dataTabOpenPolicy";
|
||||
@@ -313,6 +313,16 @@ const props = defineProps<{
|
||||
|
||||
const activeNode = shallowRef<TreeNode>(props.node);
|
||||
let acceptedSelectionIds: readonly string[] | null = null;
|
||||
let latestNavigationRequestId = 0;
|
||||
|
||||
function beginNavigationRequest(): number {
|
||||
latestNavigationRequestId += 1;
|
||||
return latestNavigationRequestId;
|
||||
}
|
||||
|
||||
function isCurrentNavigationRequest(requestId: number): boolean {
|
||||
return requestId === latestNavigationRequestId;
|
||||
}
|
||||
|
||||
function releaseActiveNodeReference(nodeIds: readonly string[]) {
|
||||
activeNode.value = releaseRemovedSidebarActionTarget(activeNode.value, nodeIds);
|
||||
@@ -599,25 +609,32 @@ function isGroupLabel(node: TreeNode): boolean {
|
||||
return groupTypes.has(node.type);
|
||||
}
|
||||
|
||||
async function openDirectNavigationNode(node: TreeNode) {
|
||||
async function openDirectNavigationNode(node: TreeNode, requestId: number) {
|
||||
if (!node.connectionId) return;
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
const connectionName = connectionStore.getConfig(node.connectionId)?.name || "Consul";
|
||||
const isNacosNavigation = node.type === "nacos-namespace" || node.type === "nacos-access-control";
|
||||
await connectionStore.ensureConnected(node.connectionId, isNacosNavigation ? { verifyHealth: false } : undefined);
|
||||
if (!isCurrentNavigationRequest(requestId)) return;
|
||||
const connectionName = connectionStore.getConfig(node.connectionId)?.name || (isNacosNavigation ? "Nacos" : "Consul");
|
||||
if (node.type === "consul-root") {
|
||||
queryStore.createTab(node.connectionId, "", `${connectionName}:keys`, "consul");
|
||||
refreshActiveKvBrowserAfterOpen("consul", node.connectionId);
|
||||
} else if (node.type === "consul-overview") {
|
||||
queryStore.createTab(node.connectionId, "", `${connectionName}:${t("consul.ui.overview")}`, "consul-overview");
|
||||
} else if (node.type === "nacos-namespace") {
|
||||
queryStore.openNacosAdmin(node.connectionId, { namespace: node.nacosNamespace || "", namespaceName: node.nacosNamespaceName || node.label });
|
||||
} else if (node.type === "nacos-access-control") {
|
||||
queryStore.createTab(node.connectionId, "", `${connectionName}:access-control`, "nacos-access-control");
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
async function toggle(requestId = beginNavigationRequest()) {
|
||||
const node = activeNode.value;
|
||||
const treeLoadSearchOptions = sidebarTreeContext?.getTreeLoadSearchOptions?.(node);
|
||||
if (isDirectNavigationTreeNode(node.type)) {
|
||||
try {
|
||||
await openDirectNavigationNode(node);
|
||||
await openDirectNavigationNode(node, requestId);
|
||||
} catch (e: any) {
|
||||
if (!isCurrentNavigationRequest(requestId)) return;
|
||||
const errMsg = e?.message || String(e);
|
||||
if (errMsg.includes(CONNECTION_ATTEMPT_CANCELLED_MESSAGE)) return;
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
|
||||
@@ -764,9 +781,6 @@ async function toggle() {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
const topicFromId = node.id.endsWith(":mqtt-topic:__console__") ? undefined : node.id.split(":mqtt-topic:")[1] || node.label;
|
||||
queryStore.openMqttAdmin(node.connectionId, topicFromId ? { initialTopic: topicFromId } : undefined);
|
||||
} else if (node.type === "nacos-namespace" && node.connectionId) {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
queryStore.openNacosAdmin(node.connectionId, { namespace: node.nacosNamespace || "", namespaceName: node.nacosNamespaceName || node.label });
|
||||
} else if (node.type === "etcd-root" && node.connectionId) {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
const tabTitle = `${connectionStore.getConfig(node.connectionId)?.name || "etcd"}:keys`;
|
||||
@@ -872,6 +886,7 @@ async function toggle() {
|
||||
}
|
||||
emitNodeToggled(node, wasExpanded);
|
||||
} catch (e: any) {
|
||||
if (!isCurrentNavigationRequest(requestId)) return;
|
||||
if (!wasExpanded) node.isExpanded = false;
|
||||
const errMsg = e?.message || String(e);
|
||||
if (errMsg.includes(CONNECTION_ATTEMPT_CANCELLED_MESSAGE)) return;
|
||||
@@ -880,7 +895,7 @@ async function toggle() {
|
||||
}
|
||||
}
|
||||
|
||||
function runRowClickAction(clickDetail: number) {
|
||||
function runRowClickAction(clickDetail: number, requestId: number) {
|
||||
const node = activeNode.value;
|
||||
if (node.type === "load-more") {
|
||||
if (clickDetail > 1) return;
|
||||
@@ -897,7 +912,10 @@ function runRowClickAction(clickDetail: number) {
|
||||
return;
|
||||
}
|
||||
const action = treeNodeRowAction(node.type, canExpand.value, settingsStore.editorSettings.sidebarActivation, currentDatabaseType(), settingsStore.editorSettings.sidebarOpenDatabaseOnSingleClick, canOpenObjectBrowser.value);
|
||||
if (!shouldRunTreeNodeRowAction(action, clickDetail, isGroupLabel(node))) return;
|
||||
// WebKit can keep incrementing click.detail while the pointer moves quickly
|
||||
// between adjacent rows. Nacos entries are idempotent navigation targets, so
|
||||
// do not mistake that rapid one-click switching for a double-click toggle.
|
||||
if (!shouldRunTreeNodeRowAction(action, clickDetail, isGroupLabel(node) || isRepeatableNavigationTreeNode(node.type))) return;
|
||||
if (action === "open-data") {
|
||||
scheduleOpenData(node);
|
||||
} else if (action === "open-object-browser") {
|
||||
@@ -914,7 +932,7 @@ function runRowClickAction(clickDetail: number) {
|
||||
} else if (isDocumentBrowserTreeNode(node.type)) {
|
||||
openMongoTreeData(node);
|
||||
} else if (action === "toggle") {
|
||||
toggle();
|
||||
void toggle(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4811,7 +4829,7 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (node.type === "etcd-root" || node.type === "etcd-dashboard" || node.type === "etcd-access-control" || node.type === "zookeeper-root" || node.type === "consul-root" || node.type === "consul-overview") {
|
||||
if (node.type === "nacos-access-control" || node.type === "etcd-root" || node.type === "etcd-dashboard" || node.type === "etcd-access-control" || node.type === "zookeeper-root" || node.type === "consul-root" || node.type === "consul-overview") {
|
||||
items.push({ label: t("contextMenu.openConnection"), action: toggle, icon: Database });
|
||||
return true;
|
||||
}
|
||||
@@ -5398,16 +5416,19 @@ function buildContextMenu(node: TreeNode): ContextMenuItem[] {
|
||||
}
|
||||
|
||||
function handleRowClick(node: TreeNode, clickDetail: number) {
|
||||
const requestId = beginNavigationRequest();
|
||||
activateRuntimeNode(node);
|
||||
runRowClickAction(clickDetail);
|
||||
runRowClickAction(clickDetail, requestId);
|
||||
}
|
||||
|
||||
function handleRowDoubleClick(node: TreeNode, event: MouseEvent) {
|
||||
beginNavigationRequest();
|
||||
activateRuntimeNode(node);
|
||||
onDoubleClick(event);
|
||||
}
|
||||
|
||||
function handleRowKeydown(node: TreeNode, event: KeyboardEvent) {
|
||||
beginNavigationRequest();
|
||||
activateRuntimeNode(node);
|
||||
onKeydown(event);
|
||||
}
|
||||
@@ -5439,7 +5460,7 @@ function requestPaste(node: TreeNode): boolean {
|
||||
|
||||
function toggleNode(node: TreeNode) {
|
||||
activateRuntimeNode(node);
|
||||
void toggle();
|
||||
void toggle(beginNavigationRequest());
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
|
||||
@@ -282,6 +282,8 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
|
||||
return { icon: FolderOpen, colorClass: "text-sky-400" };
|
||||
case "nacos-namespace":
|
||||
return { icon: FolderOpen, colorClass: "text-sky-500" };
|
||||
case "nacos-access-control":
|
||||
return { icon: ShieldCheck, colorClass: "text-sky-500" };
|
||||
case "etcd-root":
|
||||
return { icon: Database, colorClass: "text-sky-500" };
|
||||
case "etcd-dashboard":
|
||||
@@ -364,6 +366,9 @@ function isGroupLabel(node: TreeNode): boolean {
|
||||
function displayLabel(node: TreeNode): string {
|
||||
if (node.type === "load-more") return t(node.label);
|
||||
if (node.type === "object-browser") return t(node.label, { count: node.objectCount ?? 0 });
|
||||
// Use the canonical key for persisted trees created before this label was
|
||||
// internationalized; those nodes may still contain the old Chinese text.
|
||||
if (node.type === "nacos-access-control") return t("nacos.accessControlSidebarLabel");
|
||||
if (node.type === "user-admin" || node.type === "dameng-users" || node.type === "dameng-roles" || node.type === "dameng-job-admin") return t(node.label);
|
||||
if (node.type === "linked-server-root") return t(node.label);
|
||||
if (node.type === "saved-sql-root") return t(node.label);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { normalizeNacosNamespaceSelection, normalizeNacosNamespacesForDisplay } from "@/lib/nacos/nacosNamespaceVisibility";
|
||||
import { loadReadableNacosNamespaces, normalizeNacosNamespaceSelection } from "@/lib/nacos/nacosNamespaceVisibility";
|
||||
import type { NacosNamespaceInfo } from "@/types/nacos";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -65,7 +65,7 @@ async function loadNamespaces() {
|
||||
searchText.value = "";
|
||||
try {
|
||||
await connectionStore.ensureConnected(props.connectionId);
|
||||
const fetched = normalizeNacosNamespacesForDisplay(await api.nacosListNamespaces(props.connectionId));
|
||||
const fetched = await loadReadableNacosNamespaces(props.connectionId, api);
|
||||
namespaces.value = [...fetched].sort((left, right) => nacosNamespaceLabel(left).localeCompare(nacosNamespaceLabel(right)));
|
||||
connectionStore.recordPrimaryVisibleObjectNames(props.connectionId, namespaces.value.map(nacosNamespaceValue));
|
||||
const configured = connection.value?.visible_databases;
|
||||
|
||||
+74
-4
@@ -13,7 +13,8 @@ const connectionStore = {
|
||||
sidebarSearchQuery: "",
|
||||
canUseLoadedTreeNodeToggle: vi.fn(() => true),
|
||||
releaseCollapsedTreeNodeChildren: vi.fn(),
|
||||
getConfig: vi.fn(() => ({ db_type: "mysql" })),
|
||||
getConfig: vi.fn(() => ({ db_type: "mysql", name: "connection" })),
|
||||
ensureConnected: vi.fn(async () => undefined),
|
||||
loadPackageMembers: vi.fn(async (node: TreeNode) => {
|
||||
node.isExpanded = true;
|
||||
}),
|
||||
@@ -22,13 +23,18 @@ const connectionStore = {
|
||||
}),
|
||||
};
|
||||
|
||||
const queryStore = {
|
||||
createTab: vi.fn(),
|
||||
openNacosAdmin: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
CONNECTION_ATTEMPT_CANCELLED_MESSAGE: "connection attempt cancelled",
|
||||
useConnectionStore: () => connectionStore,
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({}) }));
|
||||
vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => ({ editorSettings: {} }) }));
|
||||
vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => queryStore }));
|
||||
vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => ({ editorSettings: { sidebarActivation: "single" } }) }));
|
||||
vi.mock("@/stores/savedSqlStore", () => ({ useSavedSqlStore: () => ({}) }));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
|
||||
vi.mock("@/composables/useSqlHighlighter", () => ({ useSqlHighlighter: () => ({ highlight: vi.fn() }) }));
|
||||
@@ -49,7 +55,7 @@ afterEach(() => {
|
||||
connectionStore.sidebarSearchQuery = "";
|
||||
vi.clearAllMocks();
|
||||
connectionStore.canUseLoadedTreeNodeToggle.mockReturnValue(true);
|
||||
connectionStore.getConfig.mockReturnValue({ db_type: "mysql" });
|
||||
connectionStore.getConfig.mockReturnValue({ db_type: "mysql", name: "connection" });
|
||||
});
|
||||
|
||||
describe("SidebarTreeRuntimeHost expansion", () => {
|
||||
@@ -122,6 +128,70 @@ describe("SidebarTreeRuntimeHost expansion", () => {
|
||||
expect(toggled).toHaveBeenCalledWith(packageNode, true);
|
||||
});
|
||||
|
||||
it("keeps the latest Nacos navigation click when connection checks finish out of order", async () => {
|
||||
let resolveNamespaceConnection!: () => void;
|
||||
let resolveAccessControlConnection!: () => void;
|
||||
const namespaceConnection = new Promise<void>((resolve) => {
|
||||
resolveNamespaceConnection = resolve;
|
||||
});
|
||||
const accessControlConnection = new Promise<void>((resolve) => {
|
||||
resolveAccessControlConnection = resolve;
|
||||
});
|
||||
connectionStore.ensureConnected.mockImplementationOnce(() => namespaceConnection).mockImplementationOnce(() => accessControlConnection);
|
||||
connectionStore.getConfig.mockReturnValue({ db_type: "nacos", name: "local-nacos-v2" });
|
||||
|
||||
const namespaceNode: TreeNode = {
|
||||
id: "nacos:nacos-namespace:public",
|
||||
label: "public",
|
||||
type: "nacos-namespace",
|
||||
connectionId: "nacos",
|
||||
nacosNamespace: "",
|
||||
nacosNamespaceName: "public",
|
||||
};
|
||||
const accessControlNode: TreeNode = {
|
||||
id: "nacos:nacos-access-control",
|
||||
label: "nacos.accessControlSidebarLabel",
|
||||
type: "nacos-access-control",
|
||||
connectionId: "nacos",
|
||||
// Persisted/reused tree state must not turn an application entry point
|
||||
// back into a generic expand/collapse action on the first click.
|
||||
isExpanded: true,
|
||||
children: [],
|
||||
};
|
||||
connectionStore.treeNodes = [namespaceNode, accessControlNode];
|
||||
|
||||
const host = ref<InstanceType<typeof SidebarTreeRuntimeHost> | null>(null);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup: () => () => h(SidebarTreeRuntimeHost, { ref: host, node: namespaceNode, depth: 0 }),
|
||||
}),
|
||||
);
|
||||
mountedApps.push(app);
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
app.use(i18n);
|
||||
app.mount(container);
|
||||
await nextTick();
|
||||
|
||||
expect(host.value).not.toBeNull();
|
||||
host.value!.handleRowClick(namespaceNode, 1);
|
||||
host.value!.handleRowClick(accessControlNode, 1);
|
||||
|
||||
await vi.waitFor(() => expect(connectionStore.ensureConnected).toHaveBeenCalledTimes(2));
|
||||
expect(connectionStore.ensureConnected).toHaveBeenNthCalledWith(1, "nacos", { verifyHealth: false });
|
||||
expect(connectionStore.ensureConnected).toHaveBeenNthCalledWith(2, "nacos", { verifyHealth: false });
|
||||
|
||||
resolveAccessControlConnection();
|
||||
await vi.waitFor(() => expect(queryStore.createTab).toHaveBeenCalledWith("nacos", "", "local-nacos-v2:access-control", "nacos-access-control"));
|
||||
expect(accessControlNode.isExpanded).toBe(true);
|
||||
|
||||
resolveNamespaceConnection();
|
||||
await namespaceConnection;
|
||||
await nextTick();
|
||||
|
||||
expect(queryStore.openNacosAdmin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("expands an unloaded object group without leaking the regex source as a search filter", async () => {
|
||||
const group: TreeNode = {
|
||||
id: "mysql:basic:__tables",
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = readFileSync(new URL("../VisibleNacosNamespacesDialog.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("VisibleNacosNamespacesDialog", () => {
|
||||
it("offers only namespaces readable by the current Nacos connection", () => {
|
||||
expect(source).toContain("loadReadableNacosNamespaces(props.connectionId, api)");
|
||||
expect(source).toContain("recordPrimaryVisibleObjectNames(props.connectionId, namespaces.value.map(nacosNamespaceValue))");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user