feat(nacos): add access control support

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-08-16 11:23:53 +08:00
1 parent 1838a4726e
commit c63c517b4f
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>
@@ -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');
@@ -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;
@@ -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",
@@ -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))");
});
});
+16
View File
@@ -55,6 +55,22 @@ const WINDOWS_JRE_REMOVE_ERROR = [
// Every backend message changed away from hardcoded Chinese, paired with the
// key and params it must resolve to.
const CASES: { name: string; message: string; key: string; params?: Record<string, string> }[] = [
{
name: "Nacos ordinary user must configure managed namespaces when namespace discovery is forbidden",
message: "Failed to list Nacos namespaces: NACOS_ERROR[v3ManagedNamespacesRequired]: access denied",
key: "nacos.nacosManagedNamespacesRequired",
},
{
name: "Nacos ordinary user must configure managed namespaces when authorization management is forbidden",
message: "NACOS_ERROR[managedNamespacesRequired]: 403 Forbidden",
key: "nacos.nacosManagedNamespacesRequired",
},
{
name: "Nacos 3 managed namespace permission check failed",
message: 'NACOS_ERROR[managedNamespaceAccessDenied]: One or more configured namespace IDs are not readable: namespace "team-a" naming: forbidden',
key: "nacos.nacosManagedNamespaceAccessDenied",
params: { detail: 'namespace "team-a" naming: forbidden' },
},
{
name: "Apache Phoenix JDBC driver missing",
message: PHOENIX_DRIVER_NOT_INSTALLED_ERROR,
+5
View File
@@ -96,6 +96,10 @@ const patterns: [RegExp, string][] = [
[/GBASEDBTSERVER[\s\S]*DBSERVERNAME[\s\S]*DBSERVERALIASES/, "connection.gbaseServerMismatch"],
[/^ai\.configNameExists:(.+)$/, "ai.configNameExists"],
// Ordinary users may not enumerate namespaces or authorization data through Nacos management APIs.
[/NACOS_ERROR\[(?:v3ManagedNamespacesRequired|managedNamespacesRequired)\]:[\s\S]*$/, "nacos.nacosManagedNamespacesRequired"],
[/NACOS_ERROR\[managedNamespaceAccessDenied\]: One or more configured namespace IDs are not readable: ([\s\S]+)$/, "nacos.nacosManagedNamespaceAccessDenied"],
// Tunnel / proxy test messages
[/^HTTP CONNECT proxy connection successful \((\d+)\)$/, "settings.tunnelsHttpTestSuccess"],
[/^SOCKS5 proxy connection successful$/, "settings.tunnelsSocks5TestSuccess"],
@@ -152,6 +156,7 @@ const paramNames: Record<string, string | string[]> = {
"connection.driverNotInstalled": "driver",
"connection.jreNotInstalled": "jre",
"ai.configNameExists": "name",
"nacos.nacosManagedNamespaceAccessDenied": "detail",
"settings.tunnelsHttpTestSuccess": "code",
"settings.tunnelsProxyTimedOut": "duration",
"settings.tunnelsProxyConnectFailed": "error",
+169 -4
View File
@@ -1340,6 +1340,7 @@ export default {
etcd: "etcd",
etcdDashboard: "etcd Dashboard",
etcdAccessControl: "etcd Access Control",
nacosAccessControl: "Nacos Access Control",
zookeeper: "ZooKeeper",
consul: "Consul",
mongo: "Mongo",
@@ -7586,6 +7587,161 @@ export default {
dashboardEmptyPushes: "Empty pushes",
configs: "Configs",
services: "Services",
loading: "Loading...",
accessControlSidebarLabel: "Users & Roles",
accessControlTitle: "Access control",
accessControlDescription: "Manage Nacos users and role assignments",
users: "Users",
roleBindings: "Roles",
accessControlUnavailable: "User and role management is unavailable for this connection.",
accessPermissionEndpointUnavailable: "Namespace permissions are unavailable to the current account. User and role bindings remain visible in compatibility mode.",
searchUsers: "Search users",
filterUsername: "Filter username",
filterRole: "Filter role",
username: "Username",
password: "Password",
passwordUnchanged: "Leave empty to keep the current password",
nickname: "Nickname",
roles: "Roles",
role: "Role",
disabled: "Disabled",
createUser: "Create user",
editUser: "Edit user",
resetUserPassword: "Reset password",
deleteUser: "Delete user",
noUsers: "No users found",
noRoleBindings: "No role bindings found",
createUserTitle: "Create Nacos user",
createUserDescription: "Create a user for this Nacos service.",
editUserTitle: "Edit r-nacos user",
editUserDescription: "Update the user profile, roles, and namespace privileges.",
resetUserTitle: "Reset Nacos user password",
resetUserDescription: "Set a new password for this user.",
userCredentialsRequired: "Username and password are required.",
deleteUserTitle: "Delete Nacos user",
deleteUserDescription: "Delete user {username}? This cannot be undone.",
rnacosUserEnabledHint: "Disabled users cannot sign in to r-nacos.",
rnacosRole0: "Manager",
rnacosRole0Hint: "Full console management access",
rnacosRole1: "Developer",
rnacosRole1Hint: "Configuration and service development access",
rnacosRole2: "Visitor",
rnacosRole2Hint: "Read-only console access",
namespacePrivileges: "Namespace privileges",
namespaceWhitelist: "Namespace whitelist",
namespaceBlacklist: "Namespace blacklist",
namespaceListPlaceholder: "One namespace per line or comma-separated",
namespaceSelectPlaceholder: "Select namespaces",
namespaceSearchPlaceholder: "Search namespaces",
namespaceSelectedCount: "{count} selected",
namespaceRemoveSelection: "Remove {namespace}",
noNamespacesAvailable: "No namespaces available",
retryNamespaceList: "Try again",
assignRole: "Assign role",
assignRoleTitle: "Assign Nacos role",
assignRoleDescription: "Bind a role to one Nacos user.",
roleBindingRequired: "Username and role are required.",
removeRole: "Remove role",
removeRoleTitle: "Remove Nacos role",
removeRoleDescription: "Remove role {role} from user {username}?",
protectedRole: "The built-in administrator role cannot be removed from DBX.",
close: "Close",
accessSearchRoles: "Search roles",
accessRoleCounts: "{members} members · {permissions} permissions",
accessNoRoles: "No roles found",
accessUserSubtitle: "Roles and effective namespace access",
accessAssociatedRoles: "Associated roles",
accessAssociatedRolesHint: "Permissions are inherited from every associated role.",
accessNoAssociatedRoles: "No roles associated",
accessEffectivePermissions: "Effective permissions",
accessEffectivePermissionsHint: "Combined access with the role that grants each permission.",
accessAction: "Action",
accessSourceRole: "Source role",
accessNoEffectivePermissions: "This user has no effective permissions",
accessGlobalAdministrator: "Global administrator",
accessNeedsRepair: "Needs repair",
accessRoleSubtitle: "{members} associated members",
accessIncompleteRoleHint: "This historical role has no members or namespace permissions. Add both before saving changes.",
accessPermissionScope: "Permission scope",
accessPermissionScopeHint: "Namespace rules managed by this role.",
accessAllResources: "All resources",
accessAdminBypassHint: "ROLE_ADMIN bypasses ordinary namespace permission checks.",
accessNoRolePermissions: "No namespace permissions configured",
accessMembers: "Members",
accessMembersHint: "Users currently associated with this role.",
accessNoMembers: "No associated members",
accessOpenUser: "View user {username}",
accessOpenRole: "View role {role}",
accessAdvancedPermissions: "Advanced permissions",
accessAdvancedPermissionsHint: "Raw rules are preserved exactly and are not overwritten by namespace editing.",
accessRevokeRawPermission: "Revoke raw permission",
accessRevokeRawPermissionHint: "This removes exactly the displayed role, resource, and action tuple.",
accessSelectItem: "Select an item from the directory",
accessCreateRole: "Create role",
accessDeleteRole: "Delete role",
accessReadOnly: "Read only",
accessWriteOnly: "Write only",
accessReadWrite: "Read and write",
accessDeletedNamespace: "deleted namespace",
accessPasswordRequired: "Enter a new password.",
accessCredentialsRequired: "Username and password are required.",
accessRoleMemberRequired: "A role requires at least one member.",
accessRolePermissionRequired: "A role requires at least one namespace permission.",
accessInlineUserRequired: "Every new user requires a username and password.",
accessNoAssignableRoles: "No complete roles are available",
accessTypeUsername: "Type {value} to confirm this high-risk account change",
accessMembersRequiredHint: "Select existing users or create users inline. At least one is required.",
accessInlineCreateUser: "Create user inline",
accessAdminPermissionImmutable: "The global administrator permission is built into Nacos and cannot be edited as namespace rules.",
accessPermissionRequiredHint: "Select namespaces, then set the action for each one.",
accessUnassignedNamespaces: "Unassigned namespaces",
accessGrantedNamespaces: "Granted namespaces",
accessSearchNamespaces: "Search namespaces",
accessNoUnassignedNamespaces: "No namespaces available to add",
accessNoGrantedNamespaces: "Select namespaces to grant access",
accessGrantNamespaces: "Grant selected namespaces",
accessRevokeNamespaces: "Remove selected namespaces",
accessDeleteRoleHint: "Deleting {role} removes every permission and member binding. Successful deletions are not restored after a partial failure.",
accessTypeRole: "Type {value} to confirm role deletion",
accessOperationResult: "Operation result",
accessReenterPassword: "Enter the password again to retry",
accessUndoCreated: "Undo created items",
accessRetryFailed: "Retry failed steps",
accessUserDialog: {
create: "Create user",
createHint: "Create an account and optionally associate existing roles.",
roles: "Edit user roles",
rolesHint: "Update roles associated with {username}.",
password: "Reset password",
passwordHint: "Set a new password for {username}.",
delete: "Delete user",
deleteHint: "Review role integrity before deleting {username}.",
},
accessRoleDialog: {
create: "Create role",
createHint: "Configure members and namespace permissions together.",
edit: "Edit role",
editHint: "Changes are applied as a reconciled set of member and permission operations.",
delete: "Delete role",
deleteHint: "This operation can partially succeed and cannot be undone automatically.",
},
accessOperationStatus: {
running: "The operation is still running.",
partial: "Some steps succeeded and some require attention.",
succeeded: "Every step completed successfully.",
failed: "The operation did not complete.",
undoing: "Created items are being removed.",
undone: "Created items were removed.",
},
accessStep: {
createUser: "Create user",
deleteUser: "Delete user",
assignRole: "Associate role",
removeRole: "Remove role association",
grantPermission: "Grant permission",
revokePermission: "Revoke permission",
},
rnacosAccessControlAuthDescription: "Enter the r-nacos console verification code to manage users and roles.",
raw: "Raw",
namespace: "Namespace",
createNamespace: "Create Namespace",
@@ -7828,6 +7984,15 @@ export default {
nacosConnectionPlanDescription: "Choose the service implementation and API version.",
nacosServiceAddress: "Service address",
nacosServiceAddressHint: "Enter the service address. The default port is 8848, for example http://host:8848/nacos.",
nacosManagedNamespaces: "Managed namespace IDs",
nacosManagedNamespacesPlaceholder: "For example: public, team-a",
nacosManagedNamespacesHint: "Enter the namespace IDs this ordinary user can access, separated by commas or new lines. The connection test verifies configuration and service read access for each ID.",
nacosOrdinaryAccount: "Ordinary user",
nacosOrdinaryAccountHint: "If the account above is an ordinary user, enable this option and select its authorized namespaces; otherwise the connection will not work correctly.",
nacosManagedNamespacesSeparator: "Comma or new line separated",
nacosOrdinaryNamespacesRequired: "Enter at least one namespace ID that this ordinary user is allowed to manage.",
nacosManagedNamespacesRequired: "This Nacos ordinary user cannot list namespaces or authorization data. Enter at least one namespace ID it is allowed to manage and try again.",
nacosManagedNamespaceAccessDenied: "These namespaces failed the configuration or service read-access check: {detail}",
nacosAuth: "Authentication",
nacosAuthHint: "These credentials are used for connection tests and daily management.",
nacosUsernamePassword: "Username / password",
@@ -7839,11 +8004,11 @@ export default {
nacosAdvancedDescription: "These settings have safe defaults and usually only need adjustment for custom deployments, metrics, or r-nacos extensions.",
nacosPageSizeHint: "Used for configuration and service lists; 1–500 items per page.",
nacosMetricsHint: "Query metrics from the service address automatically; choose a custom URL when the metrics endpoint differs.",
nacosRnacosExtension: "r-nacos extensions",
nacosRnacosExtensionHint: "Enables config history, config descriptions, and disabled-instance management.",
nacosRnacosExtension: "r-nacos console features",
nacosRnacosExtensionHint: "The console address and credentials enable user management, configuration details, and disabled-instance management. Configuration history is controlled separately below.",
nacosEnabled: "Enabled",
nacosRnacosConsoleUrlHint: "Usually http://host:10848.",
nacosRnacosDisabledHint: "When disabled, only the service address is used; basic configuration and service management remain available.",
nacosRnacosConsoleUrlHint: "Usually http://host:10848. Required for r-nacos user management and other console features.",
nacosRnacosDisabledHint: "Configuration history is disabled. User management remains available when a console address and credentials are configured.",
nacosTlsHint: "Enable only for a trusted self-signed HTTPS certificate.",
nacosVisibleNamespacesTitle: "Choose visible namespaces",
nacosVisibleNamespacesDetailLabel: "Visible namespaces",
+12
View File
@@ -1,6 +1,7 @@
import { withEnglishFallback } from "./fallback";
import docs from "./docs/es";
import { consulUiMessages } from "./consulUi";
import { nacosAccessControlMessages, nacosAccessControlTabs } from "./nacosAccessControl";
import { sqlServerTraceMessages as sqlServerTrace } from "./sqlServerTraceMessages";
const consul = {
@@ -1369,6 +1370,7 @@ export default withEnglishFallback({
gridfs: "GridFS",
etcdDashboard: "Panel de etcd",
etcdAccessControl: "Control de acceso de etcd",
...nacosAccessControlTabs,
},
executionSummary: {
empty: "Sin resumen",
@@ -6196,6 +6198,7 @@ export default withEnglishFallback({
runInBackground: "Ejecutar en segundo plano",
},
nacos: {
...nacosAccessControlMessages,
dashboard: "Panel",
dashboardTitle: "Panel de operaciones",
dashboardUpdatedAt: "Actualizado {time}",
@@ -6511,6 +6514,15 @@ export default withEnglishFallback({
nacosConnectionPlanDescription: "Elige la implementación del servicio y la versión de la API.",
nacosServiceAddress: "Dirección del servicio",
nacosServiceAddressHint: "Introduce la dirección del servicio. El puerto predeterminado es 8848, por ejemplo http://host:8848/nacos.",
nacosManagedNamespaces: "ID de espacios administrados",
nacosManagedNamespacesPlaceholder: "Por ejemplo: public, team-a",
nacosManagedNamespacesHint: "Introduzca los ID accesibles para este usuario normal, separados por comas o líneas. La prueba de conexión valida la lectura de configuraciones y servicios para cada ID.",
nacosOrdinaryAccount: "Usuario normal",
nacosOrdinaryAccountHint: "Si la cuenta anterior es un usuario normal, active esta opción y seleccione sus espacios autorizados; de lo contrario, la conexión no funcionará correctamente.",
nacosManagedNamespacesSeparator: "Separados por comas o líneas",
nacosOrdinaryNamespacesRequired: "Introduzca al menos un ID de espacio que este usuario normal pueda administrar.",
nacosManagedNamespacesRequired: "Este usuario normal de Nacos no puede listar los espacios ni los datos de autorización. Introduzca al menos un ID que tenga permiso para administrar y vuelva a intentarlo.",
nacosManagedNamespaceAccessDenied: "Estos espacios no superaron la comprobación de lectura de configuraciones o servicios: {detail}",
nacosAuth: "Autenticación",
nacosAuthHint: "Estas credenciales se usan para probar la conexión y para la administración diaria.",
nacosUsernamePassword: "Usuario / contraseña",
+12
View File
@@ -1,6 +1,7 @@
import { withEnglishFallback } from "./fallback";
import docs from "./docs/it";
import { consulUiMessages } from "./consulUi";
import { nacosAccessControlMessages, nacosAccessControlTabs } from "./nacosAccessControl";
import { sqlServerTraceMessages as sqlServerTrace } from "./sqlServerTraceMessages";
const consul = {
...consulUiMessages,
@@ -1367,6 +1368,7 @@ export default withEnglishFallback({
gridfs: "GridFS",
etcdDashboard: "etcd dashboard",
etcdAccessControl: "controllo accessi etcd",
...nacosAccessControlTabs,
},
executionSummary: {
empty: "Nessun riepilogo",
@@ -6196,6 +6198,7 @@ export default withEnglishFallback({
runInBackground: "Esegui in background",
},
nacos: {
...nacosAccessControlMessages,
dashboard: "Dashboard",
dashboardTitle: "Dashboard operativa",
dashboardUpdatedAt: "Aggiornato {time}",
@@ -6511,6 +6514,15 @@ export default withEnglishFallback({
nacosConnectionPlanDescription: "Scegli l'implementazione del servizio e la versione API.",
nacosServiceAddress: "Indirizzo del servizio",
nacosServiceAddressHint: "Inserisci l'indirizzo del servizio. La porta predefinita è 8848, ad esempio http://host:8848/nacos.",
nacosManagedNamespaces: "ID namespace gestiti",
nacosManagedNamespacesPlaceholder: "Ad esempio: public, team-a",
nacosManagedNamespacesHint: "Inserire gli ID accessibili a questo utente normale, separati da virgole o righe. Il test di connessione verifica la lettura di configurazioni e servizi per ogni ID.",
nacosOrdinaryAccount: "Utente normale",
nacosOrdinaryAccountHint: "Se l'account sopra è un utente normale, attivare questa opzione e selezionare i namespace autorizzati; altrimenti la connessione non funzionerà correttamente.",
nacosManagedNamespacesSeparator: "Separati da virgole o righe",
nacosOrdinaryNamespacesRequired: "Inserire almeno un ID namespace che questo utente normale può gestire.",
nacosManagedNamespacesRequired: "Questo utente normale Nacos non può elencare i namespace o i dati di autorizzazione. Inserire almeno un ID che è autorizzato a gestire e riprovare.",
nacosManagedNamespaceAccessDenied: "Questi namespace non hanno superato il controllo di lettura di configurazioni o servizi: {detail}",
nacosAuth: "Autenticazione",
nacosAuthHint: "Queste credenziali vengono usate per i test di connessione e la gestione quotidiana.",
nacosUsernamePassword: "Nome utente / password",
+12
View File
@@ -1,6 +1,7 @@
import { withEnglishFallback } from "./fallback";
import docs from "./docs/ja";
import { consulUiMessages } from "./consulUi";
import { nacosAccessControlMessages, nacosAccessControlTabs } from "./nacosAccessControl";
import { sqlServerTraceMessages as sqlServerTrace } from "./sqlServerTraceMessages";
const consul = {
@@ -1389,6 +1390,7 @@ export default withEnglishFallback({
gridfs: "GridFS",
etcdDashboard: "etcd ダッシュボード",
etcdAccessControl: "etcd アクセス制御",
...nacosAccessControlTabs,
},
executionSummary: {
empty: "サマリーはありません",
@@ -6249,6 +6251,7 @@ export default withEnglishFallback({
exportCancelled: "エクスポートがキャンセルされました",
},
nacos: {
...nacosAccessControlMessages,
dashboard: "ダッシュボード",
dashboardTitle: "運用ダッシュボード",
dashboardUpdatedAt: "更新 {time}",
@@ -6564,6 +6567,15 @@ export default withEnglishFallback({
nacosConnectionPlanDescription: "サービス実装と API バージョンを選択します。",
nacosServiceAddress: "サービスアドレス",
nacosServiceAddressHint: "サービスアドレスを入力してください。既定のポートは 8848 です(例: http://host:8848/nacos)。",
nacosManagedNamespaces: "管理対象の名前空間 ID",
nacosManagedNamespacesPlaceholder: "例: public, team-a",
nacosManagedNamespacesHint: "この一般ユーザーがアクセスできる名前空間 ID をカンマまたは改行区切りで入力してください。接続テストで各 ID の設定・サービス読み取り権限を確認します。",
nacosOrdinaryAccount: "一般ユーザー",
nacosOrdinaryAccountHint: "上記のアカウントが一般ユーザーの場合、この項目を有効にしてアクセス権のある名前空間を指定してください。指定しないと接続後に正常に使用できません。",
nacosManagedNamespacesSeparator: "カンマまたは改行区切り",
nacosOrdinaryNamespacesRequired: "一般ユーザーが管理を許可された名前空間 ID を 1 つ以上入力してください。",
nacosManagedNamespacesRequired: "この Nacos 一般ユーザーは名前空間または権限情報を一覧表示できません。管理を許可された名前空間 ID を 1 つ以上入力して再試行してください。",
nacosManagedNamespaceAccessDenied: "次の名前空間は設定またはサービスの読み取り権限チェックに失敗しました: {detail}",
nacosAuth: "認証",
nacosAuthHint: "接続テストと日常の管理に使用する認証情報です。",
nacosUsernamePassword: "ユーザー名 / パスワード",
+9
View File
@@ -7274,6 +7274,15 @@ export default withEnglishFallback({
nacosConnectionPlanDescription: "서비스 구현과 API 버전을 선택합니다.",
nacosServiceAddress: "서비스 주소",
nacosServiceAddressHint: "서비스 주소를 입력하세요. 기본 포트는 8848입니다(예: http://host:8848/nacos).",
nacosManagedNamespaces: "관리할 네임스페이스 ID",
nacosManagedNamespacesPlaceholder: "예: public, team-a",
nacosManagedNamespacesHint: "이 일반 사용자가 접근할 수 있는 네임스페이스 ID를 쉼표 또는 줄바꿈으로 입력하세요. 연결 테스트에서 각 ID의 구성 및 서비스 읽기 권한을 확인합니다.",
nacosOrdinaryAccount: "일반 사용자",
nacosOrdinaryAccountHint: "위 계정이 일반 사용자인 경우 이 옵션을 켜고 권한이 있는 네임스페이스를 지정하세요. 지정하지 않으면 연결 후 정상적으로 사용할 수 없습니다.",
nacosManagedNamespacesSeparator: "쉼표 또는 줄바꿈으로 구분",
nacosOrdinaryNamespacesRequired: "일반 사용자가 관리할 수 있는 네임스페이스 ID를 하나 이상 입력하세요.",
nacosManagedNamespacesRequired: "이 Nacos 일반 사용자는 네임스페이스 또는 권한 정보를 조회할 수 없습니다. 관리 권한이 있는 네임스페이스 ID를 하나 이상 입력한 후 다시 시도하세요.",
nacosManagedNamespaceAccessDenied: "다음 네임스페이스가 구성 또는 서비스 읽기 권한 검사에 실패했습니다: {detail}",
nacosAuth: "인증",
nacosAuthHint: "이 자격 증명은 연결 테스트와 일상적인 관리에 사용됩니다.",
nacosUsernamePassword: "사용자 이름 / 비밀번호",
@@ -0,0 +1,162 @@
// Shared English fallback for locales that have not yet translated the Nacos access-control workspace.
export const nacosAccessControlTabs = {
nacosAccessControl: "Nacos Access Control",
};
export const nacosAccessControlMessages = {
loading: "Loading...",
accessControlSidebarLabel: "Users & Roles",
accessControlTitle: "Access control",
accessControlDescription: "Manage Nacos users and role assignments",
users: "Users",
roleBindings: "Roles",
accessControlUnavailable: "User and role management is unavailable for this connection.",
accessPermissionEndpointUnavailable: "Namespace permissions are unavailable to the current account. User and role bindings remain visible in compatibility mode.",
searchUsers: "Search users",
filterUsername: "Filter username",
filterRole: "Filter role",
username: "Username",
password: "Password",
passwordUnchanged: "Leave empty to keep the current password",
nickname: "Nickname",
roles: "Roles",
role: "Role",
disabled: "Disabled",
createUser: "Create user",
editUser: "Edit user",
resetUserPassword: "Reset password",
deleteUser: "Delete user",
noUsers: "No users found",
noRoleBindings: "No role bindings found",
createUserTitle: "Create Nacos user",
createUserDescription: "Create a user for this Nacos service.",
editUserTitle: "Edit r-nacos user",
editUserDescription: "Update the user profile, roles, and namespace privileges.",
resetUserTitle: "Reset Nacos user password",
resetUserDescription: "Set a new password for this user.",
userCredentialsRequired: "Username and password are required.",
deleteUserTitle: "Delete Nacos user",
deleteUserDescription: "Delete user {username}? This cannot be undone.",
rnacosUserEnabledHint: "Disabled users cannot sign in to r-nacos.",
rnacosRole0: "Manager",
rnacosRole0Hint: "Full console management access",
rnacosRole1: "Developer",
rnacosRole1Hint: "Configuration and service development access",
rnacosRole2: "Visitor",
rnacosRole2Hint: "Read-only console access",
namespacePrivileges: "Namespace privileges",
namespaceWhitelist: "Namespace whitelist",
namespaceBlacklist: "Namespace blacklist",
namespaceListPlaceholder: "One namespace per line or comma-separated",
namespaceSelectPlaceholder: "Select namespaces",
namespaceSearchPlaceholder: "Search namespaces",
namespaceSelectedCount: "{count} selected",
namespaceRemoveSelection: "Remove {namespace}",
noNamespacesAvailable: "No namespaces available",
retryNamespaceList: "Try again",
assignRole: "Assign role",
assignRoleTitle: "Assign Nacos role",
assignRoleDescription: "Bind a role to one Nacos user.",
roleBindingRequired: "Username and role are required.",
removeRole: "Remove role",
removeRoleTitle: "Remove Nacos role",
removeRoleDescription: "Remove role {role} from user {username}?",
protectedRole: "The built-in administrator role cannot be removed from DBX.",
close: "Close",
accessSearchRoles: "Search roles",
accessRoleCounts: "{members} members / {permissions} permissions",
accessNoRoles: "No roles found",
accessUserSubtitle: "Roles and effective namespace access",
accessAssociatedRoles: "Associated roles",
accessAssociatedRolesHint: "Permissions are inherited from every associated role.",
accessNoAssociatedRoles: "No roles associated",
accessEffectivePermissions: "Effective permissions",
accessEffectivePermissionsHint: "Combined access with the role that grants each permission.",
accessAction: "Action",
accessSourceRole: "Source role",
accessNoEffectivePermissions: "This user has no effective permissions",
accessGlobalAdministrator: "Global administrator",
accessNeedsRepair: "Needs repair",
accessRoleSubtitle: "{members} associated members",
accessIncompleteRoleHint: "This historical role has no members or namespace permissions. Add both before saving changes.",
accessPermissionScope: "Permission scope",
accessPermissionScopeHint: "Namespace rules managed by this role.",
accessAllResources: "All resources",
accessAdminBypassHint: "ROLE_ADMIN bypasses ordinary namespace permission checks.",
accessNoRolePermissions: "No namespace permissions configured",
accessMembers: "Members",
accessMembersHint: "Users currently associated with this role.",
accessNoMembers: "No associated members",
accessOpenUser: "View user {username}",
accessOpenRole: "View role {role}",
accessAdvancedPermissions: "Advanced permissions",
accessAdvancedPermissionsHint: "Raw rules are preserved exactly and are not overwritten by namespace editing.",
accessRevokeRawPermission: "Revoke raw permission",
accessRevokeRawPermissionHint: "This removes exactly the displayed role, resource, and action tuple.",
accessSelectItem: "Select an item from the directory",
accessCreateRole: "Create role",
accessDeleteRole: "Delete role",
accessReadOnly: "Read only",
accessWriteOnly: "Write only",
accessReadWrite: "Read and write",
accessDeletedNamespace: "deleted namespace",
accessPasswordRequired: "Enter a new password.",
accessCredentialsRequired: "Username and password are required.",
accessRoleMemberRequired: "A role requires at least one member.",
accessRolePermissionRequired: "A role requires at least one namespace permission.",
accessInlineUserRequired: "Every new user requires a username and password.",
accessNoAssignableRoles: "No complete roles are available",
accessTypeUsername: "Type {value} to confirm this high-risk account change",
accessMembersRequiredHint: "Select existing users or create users inline. At least one is required.",
accessInlineCreateUser: "Create user inline",
accessAdminPermissionImmutable: "The global administrator permission is built into Nacos and cannot be edited as namespace rules.",
accessPermissionRequiredHint: "Select namespaces, then set the action for each one.",
accessUnassignedNamespaces: "Unassigned namespaces",
accessGrantedNamespaces: "Granted namespaces",
accessSearchNamespaces: "Search namespaces",
accessNoUnassignedNamespaces: "No namespaces available to add",
accessNoGrantedNamespaces: "Select namespaces to grant access",
accessGrantNamespaces: "Grant selected namespaces",
accessRevokeNamespaces: "Remove selected namespaces",
accessDeleteRoleHint: "Deleting {role} removes every permission and member binding. Successful deletions are not restored after a partial failure.",
accessTypeRole: "Type {value} to confirm role deletion",
accessOperationResult: "Operation result",
accessReenterPassword: "Enter the password again to retry",
accessUndoCreated: "Undo created items",
accessRetryFailed: "Retry failed steps",
accessUserDialog: {
create: "Create user",
createHint: "Create an account and optionally associate existing roles.",
roles: "Edit user roles",
rolesHint: "Update roles associated with {username}.",
password: "Reset password",
passwordHint: "Set a new password for {username}.",
delete: "Delete user",
deleteHint: "Review role integrity before deleting {username}.",
},
accessRoleDialog: {
create: "Create role",
createHint: "Configure members and namespace permissions together.",
edit: "Edit role",
editHint: "Changes are applied as a reconciled set of member and permission operations.",
delete: "Delete role",
deleteHint: "This operation can partially succeed and cannot be undone automatically.",
},
accessOperationStatus: {
running: "The operation is still running.",
partial: "Some steps succeeded and some require attention.",
succeeded: "Every step completed successfully.",
failed: "The operation did not complete.",
undoing: "Created items are being removed.",
undone: "Created items were removed.",
},
accessStep: {
createUser: "Create user",
deleteUser: "Delete user",
assignRole: "Associate role",
removeRole: "Remove role association",
grantPermission: "Grant permission",
revokePermission: "Revoke permission",
},
rnacosAccessControlAuthDescription: "Enter the r-nacos console verification code to manage users and roles.",
};
+12
View File
@@ -1,6 +1,7 @@
import { withEnglishFallback } from "./fallback";
import docs from "./docs/pt-BR";
import { consulUiMessages } from "./consulUi";
import { nacosAccessControlMessages, nacosAccessControlTabs } from "./nacosAccessControl";
import { sqlServerTraceMessages as sqlServerTrace } from "./sqlServerTraceMessages";
const consul = {
@@ -1369,6 +1370,7 @@ export default withEnglishFallback({
gridfs: "GridFS",
etcdDashboard: "painel etcd",
etcdAccessControl: "controle de acesso do etcd",
...nacosAccessControlTabs,
},
executionSummary: {
empty: "Nenhum resumo",
@@ -6198,6 +6200,7 @@ export default withEnglishFallback({
exportCancelled: "Exportação cancelada",
},
nacos: {
...nacosAccessControlMessages,
dashboard: "Painel",
dashboardTitle: "Painel de operações",
dashboardUpdatedAt: "Atualizado {time}",
@@ -6513,6 +6516,15 @@ export default withEnglishFallback({
nacosConnectionPlanDescription: "Escolha a implementação do serviço e a versão da API.",
nacosServiceAddress: "Endereço do serviço",
nacosServiceAddressHint: "Informe o endereço do serviço. A porta padrão é 8848, por exemplo http://host:8848/nacos.",
nacosManagedNamespaces: "IDs de namespaces gerenciados",
nacosManagedNamespacesPlaceholder: "Por exemplo: public, team-a",
nacosManagedNamespacesHint: "Informe os IDs acessíveis para este usuário comum, separados por vírgulas ou linhas. O teste de conexão valida a leitura de configurações e serviços para cada ID.",
nacosOrdinaryAccount: "Usuário comum",
nacosOrdinaryAccountHint: "Se a conta acima for um usuário comum, ative esta opção e selecione os namespaces autorizados; caso contrário, a conexão não funcionará corretamente.",
nacosManagedNamespacesSeparator: "Separados por vírgulas ou linhas",
nacosOrdinaryNamespacesRequired: "Informe pelo menos um ID de namespace que este usuário comum pode gerenciar.",
nacosManagedNamespacesRequired: "Este usuário comum do Nacos não pode listar namespaces nem dados de autorização. Informe pelo menos um ID que ele tenha permissão para gerenciar e tente novamente.",
nacosManagedNamespaceAccessDenied: "Estes namespaces falharam na verificação de leitura de configurações ou serviços: {detail}",
nacosAuth: "Autenticação",
nacosAuthHint: "Essas credenciais são usadas nos testes de conexão e no gerenciamento diário.",
nacosUsernamePassword: "Usuário / senha",
+169 -4
View File
@@ -1264,6 +1264,7 @@ export default withEnglishFallback({
etcd: "etcd",
etcdDashboard: "etcd 大盘",
etcdAccessControl: "etcd 访问控制",
nacosAccessControl: "Nacos 用户和角色",
zookeeper: "ZooKeeper",
consul: "Consul",
mongo: "Mongo",
@@ -7570,6 +7571,161 @@ export default withEnglishFallback({
dashboardEmptyPushes: "空推送",
configs: "配置",
services: "服务",
loading: "加载中...",
accessControlSidebarLabel: "用户和角色",
accessControlTitle: "访问控制",
accessControlDescription: "管理 Nacos 用户和角色权限",
users: "用户",
roleBindings: "角色",
accessControlUnavailable: "当前连接无法使用用户和角色管理。",
accessPermissionEndpointUnavailable: "当前账号无法访问命名空间权限接口,用户和角色绑定将以兼容模式展示。",
searchUsers: "搜索用户",
filterUsername: "筛选用户名",
filterRole: "筛选角色",
username: "用户名",
password: "密码",
passwordUnchanged: "留空则保持当前密码",
nickname: "昵称",
roles: "角色",
role: "角色",
disabled: "已禁用",
createUser: "新建用户",
editUser: "编辑用户",
resetUserPassword: "重置密码",
deleteUser: "删除用户",
noUsers: "暂无用户",
noRoleBindings: "暂无角色绑定",
createUserTitle: "新建 Nacos 用户",
createUserDescription: "为当前 Nacos 服务新建用户。",
editUserTitle: "编辑 r-nacos 用户",
editUserDescription: "更新用户资料、角色和命名空间权限。",
resetUserTitle: "重置 Nacos 用户密码",
resetUserDescription: "为该用户设置新密码。",
userCredentialsRequired: "用户名和密码不能为空。",
deleteUserTitle: "删除 Nacos 用户",
deleteUserDescription: "确定删除用户 {username} 吗?此操作不可撤销。",
rnacosUserEnabledHint: "禁用的用户无法登录 r-nacos。",
rnacosRole0: "管理员",
rnacosRole0Hint: "拥有完整控制台管理权限",
rnacosRole1: "开发者",
rnacosRole1Hint: "拥有配置和服务开发权限",
rnacosRole2: "访客",
rnacosRole2Hint: "拥有控制台只读权限",
namespacePrivileges: "命名空间权限",
namespaceWhitelist: "命名空间白名单",
namespaceBlacklist: "命名空间黑名单",
namespaceListPlaceholder: "每行一个命名空间,或用逗号分隔",
namespaceSelectPlaceholder: "选择命名空间",
namespaceSearchPlaceholder: "搜索命名空间",
namespaceSelectedCount: "已选择 {count} 个",
namespaceRemoveSelection: "移除 {namespace}",
noNamespacesAvailable: "暂无可选命名空间",
retryNamespaceList: "重新加载",
assignRole: "分配角色",
assignRoleTitle: "分配 Nacos 角色",
assignRoleDescription: "将一个角色绑定到指定 Nacos 用户。",
roleBindingRequired: "用户名和角色不能为空。",
removeRole: "移除角色",
removeRoleTitle: "移除 Nacos 角色",
removeRoleDescription: "确定从用户 {username} 移除角色 {role} 吗?",
protectedRole: "DBX 不允许移除内置管理员角色。",
close: "关闭",
accessSearchRoles: "搜索角色",
accessRoleCounts: "{members} 位成员 · {permissions} 条权限",
accessNoRoles: "暂无角色",
accessUserSubtitle: "关联角色与最终生效的命名空间权限",
accessAssociatedRoles: "关联角色",
accessAssociatedRolesHint: "用户会继承所有关联角色的权限。",
accessNoAssociatedRoles: "尚未关联角色",
accessEffectivePermissions: "有效权限",
accessEffectivePermissionsHint: "合并展示最终权限,并标明每项权限的来源角色。",
accessAction: "动作",
accessSourceRole: "来源角色",
accessNoEffectivePermissions: "该用户暂无有效权限",
accessGlobalAdministrator: "全局管理员",
accessNeedsRepair: "需要修复",
accessRoleSubtitle: "已关联 {members} 位成员",
accessIncompleteRoleHint: "这是一个历史异常角色,缺少成员或命名空间权限。保存修改前必须补齐两项配置。",
accessPermissionScope: "权限范围",
accessPermissionScopeHint: "由该角色管理的命名空间权限规则。",
accessAllResources: "全部资源",
accessAdminBypassHint: "ROLE_ADMIN 会绕过普通命名空间权限检查。",
accessNoRolePermissions: "尚未配置命名空间权限",
accessMembers: "关联成员",
accessMembersHint: "当前与该角色关联的用户。",
accessNoMembers: "尚未关联成员",
accessOpenUser: "查看用户 {username}",
accessOpenRole: "查看角色 {role}",
accessAdvancedPermissions: "高级权限",
accessAdvancedPermissionsHint: "原始权限会被完整保留,不会被普通命名空间编辑覆盖。",
accessRevokeRawPermission: "撤销原始权限",
accessRevokeRawPermissionHint: "仅撤销当前显示的角色、资源和动作三元组。",
accessSelectItem: "请从左侧目录选择一项",
accessCreateRole: "新建角色",
accessDeleteRole: "删除角色",
accessReadOnly: "只读",
accessWriteOnly: "只写",
accessReadWrite: "读写",
accessDeletedNamespace: "已删除命名空间",
accessPasswordRequired: "请输入新密码。",
accessCredentialsRequired: "用户名和密码不能为空。",
accessRoleMemberRequired: "角色至少需要一位成员。",
accessRolePermissionRequired: "角色至少需要一条命名空间权限。",
accessInlineUserRequired: "每个待创建用户都必须填写用户名和密码。",
accessNoAssignableRoles: "暂无可分配的完整角色",
accessTypeUsername: "输入 {value} 确认此次高风险账号变更",
accessMembersRequiredHint: "选择已有用户或直接创建新用户,至少需要一位成员。",
accessInlineCreateUser: "同时创建用户",
accessAdminPermissionImmutable: "全局管理员权限由 Nacos 内置,不能按普通命名空间权限编辑。",
accessPermissionRequiredHint: "选择命名空间后,为每一项分别设置权限动作。",
accessUnassignedNamespaces: "未授权命名空间",
accessGrantedNamespaces: "已授权命名空间",
accessSearchNamespaces: "搜索命名空间",
accessNoUnassignedNamespaces: "没有可添加的命名空间",
accessNoGrantedNamespaces: "请选择需要授权的命名空间",
accessGrantNamespaces: "授予选中的命名空间",
accessRevokeNamespaces: "移除选中的命名空间",
accessDeleteRoleHint: "删除 {role} 会移除其全部权限和成员绑定。部分成功后,已删除内容不会自动恢复。",
accessTypeRole: "输入 {value} 确认删除角色",
accessOperationResult: "操作结果",
accessReenterPassword: "重新输入密码后重试",
accessUndoCreated: "撤销本次创建",
accessRetryFailed: "重试失败项",
accessUserDialog: {
create: "新建用户",
createHint: "创建账号,并可同时关联已有角色。",
roles: "编辑用户角色",
rolesHint: "调整用户 {username} 关联的角色。",
password: "重置密码",
passwordHint: "为用户 {username} 设置新密码。",
delete: "删除用户",
deleteHint: "删除 {username} 前会检查角色完整性。",
},
accessRoleDialog: {
create: "新建角色",
createHint: "一次完成角色成员和命名空间权限配置。",
edit: "编辑角色",
editHint: "成员和权限变更会经过服务端对账后按步骤执行。",
delete: "删除角色",
deleteHint: "此操作可能部分成功,且不支持自动恢复已删除内容。",
},
accessOperationStatus: {
running: "操作仍在执行中。",
partial: "部分步骤成功,仍有步骤需要处理。",
succeeded: "所有步骤均已成功完成。",
failed: "操作未能完成。",
undoing: "正在移除本次创建的内容。",
undone: "本次创建的内容已移除。",
},
accessStep: {
createUser: "创建用户",
deleteUser: "删除用户",
assignRole: "关联角色",
removeRole: "移除角色关联",
grantPermission: "授予权限",
revokePermission: "撤销权限",
},
rnacosAccessControlAuthDescription: "请输入 r-nacos 控制台验证码,以管理用户和角色。",
raw: "Raw",
namespace: "命名空间",
createNamespace: "新建命名空间",
@@ -7812,6 +7968,15 @@ export default withEnglishFallback({
nacosConnectionPlanDescription: "选择服务实现和 API 版本。",
nacosServiceAddress: "服务地址",
nacosServiceAddressHint: "填写服务地址即可,默认端口为 8848,例如 http://主机:8848/nacos。",
nacosManagedNamespaces: "可管理命名空间 ID",
nacosManagedNamespacesPlaceholder: "例如:public, team-a",
nacosManagedNamespacesHint: "填写该普通用户有权访问的命名空间 ID,支持逗号或换行分隔;连接测试会逐项验证配置与服务读取权限。",
nacosOrdinaryAccount: "普通用户",
nacosOrdinaryAccountHint: "如果上方填写的是普通用户,请开启此选项并指定该用户有权限的命名空间,否则连接后将无法正常使用。",
nacosManagedNamespacesSeparator: "逗号或换行分隔",
nacosOrdinaryNamespacesRequired: "普通用户至少需要填写一个有权管理的命名空间 ID。",
nacosManagedNamespacesRequired: "当前 Nacos 普通用户无权获取命名空间目录或权限管理信息,请填写至少一个有权管理的命名空间 ID 后重试。",
nacosManagedNamespaceAccessDenied: "以下命名空间未通过配置或服务读取权限校验:{detail}",
nacosAuth: "认证",
nacosAuthHint: "认证信息同时用于连接测试和日常管理。",
nacosUsernamePassword: "账号密码",
@@ -7823,11 +7988,11 @@ export default withEnglishFallback({
nacosAdvancedDescription: "这些参数均有安全默认值,通常只有自定义部署、指标采集或 r-nacos 扩展能力需要调整。",
nacosPageSizeHint: "用于配置和服务列表,每页 1–500 条。",
nacosMetricsHint: "自动使用服务地址查询指标;若指标端点不同,请选择“自定义地址”。",
nacosRnacosExtension: "r-nacos 扩展控制台",
nacosRnacosExtensionHint: "启用后可使用配置历史、配置描述和禁用实例管理。",
nacosRnacosExtension: "r-nacos 控制台功能",
nacosRnacosExtensionHint: "控制台地址和凭据用于用户管理、配置详情和禁用实例管理;配置历史由下方开关单独控制。",
nacosEnabled: "启用",
nacosRnacosConsoleUrlHint: "通常为 http://主机:10848。",
nacosRnacosDisabledHint: "未启用时仅使用服务地址,基础配置和服务管理仍可用。",
nacosRnacosConsoleUrlHint: "通常为 http://主机:10848。r-nacos 用户管理及其他控制台功能需要此地址。",
nacosRnacosDisabledHint: "配置历史已禁用;只要配置了控制台地址和凭据,用户管理仍可使用。",
nacosTlsHint: "仅在使用自签名 HTTPS 证书且确认目标可信时开启。",
nacosVisibleNamespacesTitle: "选择显示的命名空间",
nacosVisibleNamespacesDetailLabel: "可见命名空间",
+13
View File
@@ -1,6 +1,7 @@
import { withEnglishFallback } from "./fallback";
import docs from "./docs/zh-TW";
import { consulUiMessages } from "./consulUi";
import { nacosAccessControlMessages, nacosAccessControlTabs } from "./nacosAccessControl";
import { sqlServerTraceMessages as sqlServerTrace } from "./sqlServerTraceMessages";
const consul = {
@@ -1368,6 +1369,7 @@ export default withEnglishFallback({
gridfs: "GridFS",
etcdDashboard: "etcd 儀表板",
etcdAccessControl: "etcd 存取控制",
...nacosAccessControlTabs,
},
executionSummary: {
empty: "無摘要",
@@ -5535,6 +5537,8 @@ export default withEnglishFallback({
runInBackground: "背景執行",
},
nacos: {
...nacosAccessControlMessages,
accessControlSidebarLabel: "使用者和角色",
dashboard: "儀表板",
dashboardTitle: "運行儀表板",
dashboardUpdatedAt: "更新於 {time}",
@@ -5850,6 +5854,15 @@ export default withEnglishFallback({
nacosConnectionPlanDescription: "選擇服務實作與 API 版本。",
nacosServiceAddress: "服務位址",
nacosServiceAddressHint: "填寫服務位址即可,預設連接埠為 8848,例如 http://host:8848/nacos。",
nacosManagedNamespaces: "可管理命名空間 ID",
nacosManagedNamespacesPlaceholder: "例如:public, team-a",
nacosManagedNamespacesHint: "填寫該一般使用者有權存取的命名空間 ID,支援逗號或換行分隔;連線測試會逐項驗證設定與服務讀取權限。",
nacosOrdinaryAccount: "一般使用者",
nacosOrdinaryAccountHint: "如果上方填寫的是一般使用者,請開啟此選項並指定該使用者有權限的命名空間,否則連線後將無法正常使用。",
nacosManagedNamespacesSeparator: "以逗號或換行分隔",
nacosOrdinaryNamespacesRequired: "一般使用者至少需要填寫一個有權管理的命名空間 ID。",
nacosManagedNamespacesRequired: "目前的 Nacos 一般使用者無權取得命名空間目錄或權限管理資訊,請填寫至少一個有權管理的命名空間 ID 後重試。",
nacosManagedNamespaceAccessDenied: "以下命名空間未通過設定或服務讀取權限驗證:{detail}",
nacosAuth: "驗證",
nacosAuthHint: "這些驗證資訊會用於連線測試與日常管理。",
nacosUsernamePassword: "使用者名稱 / 密碼",
@@ -24,7 +24,13 @@ describe("Nacos connection dialog layout", () => {
expect(main).toContain('t("nacos.nacosAdvancedHint")');
expect(main).toContain("@click=\"configTab = 'advanced'\"");
expect(main).toContain('v-model="nacosServerAddr"');
expect(main).not.toContain('v-model="nacosV3ConsoleAddr"');
expect(main).toContain('v-model="nacosManagedNamespacesText"');
expect(main).toContain('t("nacos.nacosManagedNamespacesHint")');
expect(main).toContain("data-nacos-ordinary-user-toggle");
expect(main).toContain('v-model="nacosOrdinaryAccount"');
expect(main).toContain("nacosAuthKind === 'usernamePassword'");
expect(main).toContain("nacosImplementation === 'nacos' && nacosAuthKind === 'usernamePassword'");
expect(main).not.toContain("nacosVersionMode === 'v3' && nacosAuthKind === 'usernamePassword'");
expect(main).not.toContain('v-model="nacosNamespace"');
expect(main).toContain('t("nacos.nacosAuthHint")');
expect(main).not.toContain('v-model="nacosMetricsMode"');
@@ -40,8 +46,8 @@ describe("Nacos connection dialog layout", () => {
expect(advanced).not.toContain("配置上下文路径");
expect(advanced).toContain('v-model="nacosMetricsMode"');
expect(advanced).toContain('v-model="nacosRNacosConsoleAddr"');
expect(advanced).toContain('v-if="nacosHistoryEnabled"');
expect(advanced).toContain('t("nacos.nacosRnacosDisabledHint")');
expect(advanced).toContain('v-if="nacosRNacosConsoleAddr.trim()"');
expect(advanced).toContain('v-model="nacosHistoryEnabled"');
expect(advanced).toContain('v-model="nacosTlsSkipVerify"');
expect(advanced).toContain('v-model.number="nacosPageSize"');
});
@@ -52,6 +58,7 @@ describe("Nacos connection dialog layout", () => {
expect(source).not.toContain("DBX 不需要配置该地址");
const mainStart = source.indexOf("data-nacos-profile-selector");
const mainEnd = source.indexOf("<!-- Redis: host, port, user, password, ssl -->", mainStart);
expect(source.slice(mainStart, mainEnd)).toContain("data-nacos-managed-namespaces");
expect(source.slice(mainStart, mainEnd)).not.toContain('placeholder="http://127.0.0.1:8080"');
expect(source).not.toContain("http://127.0.0.1:8010");
expect(source).not.toContain("http://127.0.0.1:8818");
@@ -60,7 +67,7 @@ describe("Nacos connection dialog layout", () => {
it("uses a dedicated namespace selector instead of the database selector", () => {
expect(source).toContain('t("nacos.nacosVisibleNamespacesTitle")');
expect(source).toContain("openVisibleNacosNamespacesPicker");
expect(source).toContain("api.nacosListNamespaces(draftId)");
expect(source).toContain("loadReadableNacosNamespaces(draftId, api)");
expect(source).toContain("showVisibleNacosNamespacesDialog");
});
@@ -68,7 +75,7 @@ describe("Nacos connection dialog layout", () => {
expect(source).toContain('form.value.db_type === "nacos"');
expect(source).toContain("production.allNamespaces");
expect(source).toContain("production.namespacePickerTitle");
expect(source).toContain("api.nacosListNamespaces(connectionId)");
expect(source).toContain("loadReadableNacosNamespaces(connectionId, api)");
expect(source).toContain("nacosNamespaceIdentity(name)");
});
});
+21
View File
@@ -18,9 +18,11 @@ import {
isNacosErrorCode,
isNacosConfigSaveSnapshotCurrent,
isNacosConfigDeleteSnapshotInScope,
mergeNacosNamespacePermissionAssignments,
nacosConfigFileExtension,
nacosMetricsCandidates,
normalizeNacosMetricsUrl,
parseNacosManagedNamespaces,
parseNacosRawBody,
parseNacosRawQuery,
normalizeNacosEndpoint,
@@ -79,6 +81,7 @@ describe("nacosAdmin helpers", () => {
expect(() => normalizeNacosMetricsUrl("http://localhost/metrics#fragment")).toThrow(/fragment/);
expect(() => normalizeNacosMetricsUrl("http://localhost/metrics#")).toThrow(/fragment/);
});
it("parses raw query and body text", () => {
expect(parseNacosRawQuery("?dataId=a&group=DEFAULT_GROUP")).toEqual({ dataId: "a", group: "DEFAULT_GROUP" });
expect(parseNacosRawQuery("")).toBeUndefined();
@@ -86,6 +89,24 @@ describe("nacosAdmin helpers", () => {
expect(parseNacosRawBody("plain text")).toBe("plain text");
});
it("parses, trims, and deduplicates managed namespace IDs", () => {
expect(parseNacosManagedNamespaces(" public, team-a\nteam-a,team-b\n\n")).toEqual(["public", "team-a", "team-b"]);
expect(parseNacosManagedNamespaces(" ")).toEqual([]);
});
it("combines split read and write permission rows before role editing", () => {
expect(
mergeNacosNamespacePermissionAssignments([
{ actionRaw: "r", parsedScope: { kind: "namespace", namespaceId: "team-a" } },
{ actionRaw: "w", parsedScope: { kind: "namespace", namespaceId: "team-a" } },
{ actionRaw: "r", parsedScope: { kind: "namespace", namespaceId: "team-b" } },
]),
).toEqual([
{ namespaceId: "team-a", action: "rw" },
{ namespaceId: "team-b", action: "r" },
]);
});
it("builds raw requests and detects mutations", () => {
const req = buildNacosRawRequest("post", " /v1/cs/configs ", "a=1", '{"b":2}');
expect(req).toEqual({ method: "post", path: "/v1/cs/configs", query: { a: "1" }, body: { b: 2 } });
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { filterNacosNamespacesForSidebar, normalizeNacosNamespaceSelection, normalizeNacosNamespacesForDisplay } from "@/lib/nacos/nacosNamespaceVisibility";
import { describe, expect, it, vi } from "vitest";
import { filterNacosNamespacesForSidebar, loadReadableNacosNamespaces, normalizeNacosNamespaceSelection, normalizeNacosNamespacesForDisplay } from "@/lib/nacos/nacosNamespaceVisibility";
import { nacosVisibleNamespaceSummary } from "@/lib/sidebar/sidebarVisibleFilterSummary";
const namespaces = [
@@ -41,3 +41,12 @@ describe("filterNacosNamespacesForSidebar", () => {
});
});
});
describe("loadReadableNacosNamespaces", () => {
it("uses the backend-filtered list without per-namespace frontend probes", async () => {
const nacosListNamespaces = vi.fn().mockResolvedValue([namespaces[1]]);
await expect(loadReadableNacosNamespaces("nacos", { nacosListNamespaces })).resolves.toEqual([namespaces[1]]);
expect(nacosListNamespaces).toHaveBeenCalledTimes(1);
});
});
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { filterSidebarTreeToConnectedConnections } from "@/lib/sidebar/sidebarSearchTree";
import { filterSidebarTree, filterSidebarTreeToConnectedConnections } from "@/lib/sidebar/sidebarSearchTree";
import type { TreeNode } from "@/types/database";
function connection(id: string): TreeNode {
@@ -38,3 +38,17 @@ describe("connected sidebar connection filter", () => {
expect(filterSidebarTreeToConnectedConnections(tree, new Set(["first"]))).toBe(tree);
});
});
describe("sidebar tree translated labels", () => {
it("matches an application entry by its localized display label", () => {
const node: TreeNode = {
id: "nacos:nacos-access-control",
label: "nacos.accessControlSidebarLabel",
type: "nacos-access-control",
connectionId: "nacos",
};
expect(filterSidebarTree([node], "Nacos Access Control", new Set(), undefined, () => "Nacos Access Control")).toEqual([node]);
expect(filterSidebarTree([node], "Nacos 用户和角色", new Set(), undefined, () => "Nacos 用户和角色")).toEqual([node]);
});
});
@@ -27,7 +27,9 @@ describe("sidebar tree item layout", () => {
expect(alignedCommentLeadingWidth(undefined, true)).toBeUndefined();
});
it("renders etcd leaf actions without expanders", () => {
it("renders navigation leaf actions without expanders", () => {
expect(canTreeNodeShowExpander({ type: "nacos-namespace", childCount: 0 })).toBe(false);
expect(canTreeNodeShowExpander({ type: "nacos-access-control", childCount: 0 })).toBe(false);
expect(canTreeNodeShowExpander({ type: "etcd-root", childCount: 0 })).toBe(false);
expect(canTreeNodeShowExpander({ type: "etcd-dashboard", childCount: 0 })).toBe(false);
expect(canTreeNodeShowExpander({ type: "etcd-access-control", childCount: 0 })).toBe(false);
@@ -1,5 +1,15 @@
import { describe, expect, it } from "vitest";
import { isDirectNavigationTreeNode, objectSourceKindForTreeNode, objectSourceTargetForTreeNode, shouldActivateTreeNodeOnSingleClick, shouldOpenObjectBrowserOnSingleClick, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/sidebar/treeNodeClick";
import {
isDirectNavigationTreeNode,
isRepeatableNavigationTreeNode,
objectSourceKindForTreeNode,
objectSourceTargetForTreeNode,
shouldActivateTreeNodeOnSingleClick,
shouldOpenObjectBrowserOnSingleClick,
shouldRunTreeNodeRowAction,
treeNodeRowAction,
treeNodeRowDoubleClickAction,
} from "@/lib/sidebar/treeNodeClick";
describe("treeNodeClick", () => {
it("opens synonym nodes as synonym source", () => {
@@ -44,6 +54,20 @@ describe("treeNodeClick", () => {
expect(treeNodeRowAction("consul-overview", false, "double")).toBe("toggle");
});
it("keeps Nacos namespace and access-control navigation responsive during rapid row switching", () => {
for (const type of ["nacos-namespace", "nacos-access-control"] as const) {
expect(isDirectNavigationTreeNode(type), type).toBe(true);
expect(shouldActivateTreeNodeOnSingleClick(type, "double"), type).toBe(true);
const action = treeNodeRowAction(type, false, "double");
expect(action, type).toBe("toggle");
expect(isRepeatableNavigationTreeNode(type), type).toBe(true);
expect(shouldRunTreeNodeRowAction(action, 2, isRepeatableNavigationTreeNode(type)), type).toBe(true);
}
expect(isRepeatableNavigationTreeNode("database")).toBe(false);
expect(shouldRunTreeNodeRowAction("toggle", 2, false)).toBe(false);
});
it("opens the connection database browser only when the capability is enabled", () => {
expect(treeNodeRowDoubleClickAction("connection", false, "single", true, "postgres", true)).toBe("open-database-browser");
expect(treeNodeRowDoubleClickAction("connection", false, "double", true, "postgres", false)).toBe("toggle");
+13
View File
@@ -357,6 +357,7 @@ export const listSqlFilesInFolder = forward("listSqlFilesInFolder");
// Nacos
export const nacosTestConnection = forward("nacosTestConnection");
export const nacosListNamespaces = forward("nacosListNamespaces");
export const nacosSidebarSnapshot = forward("nacosSidebarSnapshot");
export const nacosCreateNamespace = forward("nacosCreateNamespace");
export const nacosUpdateNamespace = forward("nacosUpdateNamespace");
export const nacosListConfigs = forward("nacosListConfigs");
@@ -375,6 +376,18 @@ export const nacosGetConfigHistory = forward("nacosGetConfigHistory");
export const nacosRollbackConfig = forward("nacosRollbackConfig");
export const nacosGetRNacosConsoleCaptcha = forward("nacosGetRNacosConsoleCaptcha");
export const nacosLoginRNacosConsole = forward("nacosLoginRNacosConsole");
export const nacosListUsers = forward("nacosListUsers");
export const nacosCreateUser = forward("nacosCreateUser");
export const nacosUpdateUser = forward("nacosUpdateUser");
export const nacosDeleteUser = forward("nacosDeleteUser");
export const nacosListRoleBindings = forward("nacosListRoleBindings");
export const nacosAssignRole = forward("nacosAssignRole");
export const nacosRemoveRole = forward("nacosRemoveRole");
export const nacosAccessSnapshot = forward("nacosAccessSnapshot");
export const nacosStartAccessOperation = forward("nacosStartAccessOperation");
export const nacosGetAccessOperation = forward("nacosGetAccessOperation");
export const nacosRetryAccessOperation = forward("nacosRetryAccessOperation");
export const nacosUndoAccessOperation = forward("nacosUndoAccessOperation");
export const nacosListServices = forward("nacosListServices");
export const nacosGetService = forward("nacosGetService");
export const nacosCreateService = forward("nacosCreateService");
+64
View File
@@ -185,6 +185,17 @@ import type {
NacosConfigUpsert,
NacosConnectionInfo,
NacosRNacosConsoleCaptcha,
NacosUserQuery,
NacosUserList,
NacosUserCreate,
NacosUserUpdate,
NacosRoleQuery,
NacosRoleList,
NacosRoleBinding,
NacosAccessControlSnapshot,
NacosAccessOperationRequest,
NacosAccessOperationResult,
NacosAccessOperationRetry,
NacosInstanceInfo,
NacosInstanceRef,
NacosInstanceRegistration,
@@ -194,6 +205,7 @@ import type {
NacosDashboardSnapshot,
NacosNamespaceCreate,
NacosNamespaceInfo,
NacosNamespaceSidebarSnapshot,
NacosNamespaceUpdate,
NacosRawRequest,
NacosRawResponse,
@@ -3162,6 +3174,10 @@ export async function nacosListNamespaces(connectionId: string): Promise<NacosNa
return post("/api/nacos/namespaces/list", { connectionId });
}
export async function nacosSidebarSnapshot(connectionId: string): Promise<NacosNamespaceSidebarSnapshot> {
return post("/api/nacos/sidebar/snapshot", { connectionId });
}
export async function nacosCreateNamespace(connectionId: string, req: NacosNamespaceCreate): Promise<void> {
return post("/api/nacos/namespaces/create", { connectionId, req });
}
@@ -3303,6 +3319,54 @@ export async function nacosLoginRNacosConsole(connectionId: string, captcha?: st
return post("/api/nacos/rnacos-console/login", { connectionId, captcha });
}
export async function nacosListUsers(connectionId: string, query: NacosUserQuery): Promise<NacosUserList> {
return post("/api/nacos/users/list", { connectionId, query });
}
export async function nacosCreateUser(connectionId: string, req: NacosUserCreate): Promise<void> {
return post("/api/nacos/users/create", { connectionId, req });
}
export async function nacosUpdateUser(connectionId: string, req: NacosUserUpdate): Promise<void> {
return post("/api/nacos/users/update", { connectionId, req });
}
export async function nacosDeleteUser(connectionId: string, username: string): Promise<void> {
return post("/api/nacos/users/delete", { connectionId, username });
}
export async function nacosListRoleBindings(connectionId: string, query: NacosRoleQuery): Promise<NacosRoleList> {
return post("/api/nacos/roles/list", { connectionId, query });
}
export async function nacosAssignRole(connectionId: string, binding: NacosRoleBinding): Promise<void> {
return post("/api/nacos/roles/assign", { connectionId, binding });
}
export async function nacosRemoveRole(connectionId: string, binding: NacosRoleBinding): Promise<void> {
return post("/api/nacos/roles/remove", { connectionId, binding });
}
export async function nacosAccessSnapshot(connectionId: string): Promise<NacosAccessControlSnapshot> {
return post("/api/nacos/access/snapshot", { connectionId });
}
export async function nacosStartAccessOperation(connectionId: string, req: NacosAccessOperationRequest): Promise<NacosAccessOperationResult> {
return post("/api/nacos/access/operations/start", { connectionId, req });
}
export async function nacosGetAccessOperation(connectionId: string, operationId: string): Promise<NacosAccessOperationResult> {
return post("/api/nacos/access/operations/get", { connectionId, operationId });
}
export async function nacosRetryAccessOperation(connectionId: string, retry: NacosAccessOperationRetry): Promise<NacosAccessOperationResult> {
return post("/api/nacos/access/operations/retry", { connectionId, retry });
}
export async function nacosUndoAccessOperation(connectionId: string, operationId: string): Promise<NacosAccessOperationResult> {
return post("/api/nacos/access/operations/undo", { connectionId, operationId });
}
export async function nacosListServices(connectionId: string, query: NacosServiceQuery): Promise<NacosServiceList> {
return post("/api/nacos/services/list", { connectionId, query });
}
@@ -20,6 +20,17 @@ import type {
NacosDashboardQuery,
NacosDashboardSnapshot,
NacosRNacosConsoleCaptcha,
NacosUserQuery,
NacosUserList,
NacosUserCreate,
NacosUserUpdate,
NacosRoleQuery,
NacosRoleList,
NacosRoleBinding,
NacosAccessControlSnapshot,
NacosAccessOperationRequest,
NacosAccessOperationResult,
NacosAccessOperationRetry,
NacosInstanceInfo,
NacosInstanceRef,
NacosInstanceRegistration,
@@ -27,6 +38,7 @@ import type {
NacosInstanceUpdateRequest,
NacosNamespaceCreate,
NacosNamespaceInfo,
NacosNamespaceSidebarSnapshot,
NacosNamespaceUpdate,
NacosRawRequest,
NacosRawResponse,
@@ -45,6 +57,10 @@ export async function nacosListNamespaces(connectionId: string): Promise<NacosNa
return invoke("nacos_list_namespaces", { connectionId });
}
export async function nacosSidebarSnapshot(connectionId: string): Promise<NacosNamespaceSidebarSnapshot> {
return invoke("nacos_sidebar_snapshot", { connectionId });
}
export async function nacosCreateNamespace(connectionId: string, req: NacosNamespaceCreate): Promise<void> {
return invoke("nacos_create_namespace", { connectionId, req });
}
@@ -121,6 +137,54 @@ export async function nacosLoginRNacosConsole(connectionId: string, captcha?: st
return invoke("nacos_login_rnacos_console", { connectionId, captcha });
}
export async function nacosListUsers(connectionId: string, query: NacosUserQuery): Promise<NacosUserList> {
return invoke("nacos_list_users", { connectionId, query });
}
export async function nacosCreateUser(connectionId: string, req: NacosUserCreate): Promise<void> {
return invoke("nacos_create_user", { connectionId, req });
}
export async function nacosUpdateUser(connectionId: string, req: NacosUserUpdate): Promise<void> {
return invoke("nacos_update_user", { connectionId, req });
}
export async function nacosDeleteUser(connectionId: string, username: string): Promise<void> {
return invoke("nacos_delete_user", { connectionId, username });
}
export async function nacosListRoleBindings(connectionId: string, query: NacosRoleQuery): Promise<NacosRoleList> {
return invoke("nacos_list_role_bindings", { connectionId, query });
}
export async function nacosAssignRole(connectionId: string, binding: NacosRoleBinding): Promise<void> {
return invoke("nacos_assign_role", { connectionId, binding });
}
export async function nacosRemoveRole(connectionId: string, binding: NacosRoleBinding): Promise<void> {
return invoke("nacos_remove_role", { connectionId, binding });
}
export async function nacosAccessSnapshot(connectionId: string): Promise<NacosAccessControlSnapshot> {
return invoke("nacos_access_snapshot", { connectionId });
}
export async function nacosStartAccessOperation(connectionId: string, req: NacosAccessOperationRequest): Promise<NacosAccessOperationResult> {
return invoke("nacos_start_access_operation", { connectionId, req });
}
export async function nacosGetAccessOperation(connectionId: string, operationId: string): Promise<NacosAccessOperationResult> {
return invoke("nacos_get_access_operation", { connectionId, operationId });
}
export async function nacosRetryAccessOperation(connectionId: string, retry: NacosAccessOperationRetry): Promise<NacosAccessOperationResult> {
return invoke("nacos_retry_access_operation", { connectionId, retry });
}
export async function nacosUndoAccessOperation(connectionId: string, operationId: string): Promise<NacosAccessOperationResult> {
return invoke("nacos_undo_access_operation", { connectionId, operationId });
}
export async function nacosListServices(connectionId: string, query: NacosServiceQuery): Promise<NacosServiceList> {
return invoke("nacos_list_services", { connectionId, query });
}
+38 -1
View File
@@ -1,4 +1,4 @@
import type { NacosConfigHistoryItem, NacosConfigItem, NacosConfigKey, NacosContentMatch, NacosImplementation, NacosInstanceInfo, NacosRawRequest, NacosServiceInfo, NacosVersionMode } from "@/types/nacos";
import type { NacosConfigHistoryItem, NacosConfigItem, NacosConfigKey, NacosContentMatch, NacosImplementation, NacosInstanceInfo, NacosPermissionInfo, NacosRawRequest, NacosServiceInfo, NacosVersionMode } from "@/types/nacos";
import { diffArrays, diffChars } from "diff";
export type NacosRawTemplateKey = "serverState" | "namespaceList" | "configDetail" | "serviceList" | "instanceList";
@@ -162,6 +162,43 @@ export function parseNacosRawQuery(text: string): Record<string, string> | undef
return Object.fromEntries(new URLSearchParams(trimmed).entries());
}
export function parseNacosManagedNamespaces(text: string): string[] {
return [
...new Set(
text
.split(/[\n,,]+/)
.map((value) => value.trim())
.filter(Boolean),
),
];
}
export type NacosNamespacePermissionAction = "r" | "w" | "rw";
export interface NacosNamespacePermissionAssignment {
namespaceId: string;
action: NacosNamespacePermissionAction;
}
/**
* The Nacos permission table may store read and write as separate rows for the
* same role and namespace. The editor exposes one assignment per namespace, so
* combine those rows before populating the form to avoid dropping either half
* when an unchanged role is saved.
*/
export function mergeNacosNamespacePermissionAssignments(permissions: Pick<NacosPermissionInfo, "actionRaw" | "parsedScope">[]): NacosNamespacePermissionAssignment[] {
const assignments = new Map<string, NacosNamespacePermissionAction>();
for (const permission of permissions) {
const namespaceId = permission.parsedScope?.namespaceId;
if (!namespaceId || !["r", "w", "rw"].includes(permission.actionRaw)) continue;
const current = assignments.get(namespaceId);
const readable = current?.includes("r") || permission.actionRaw.includes("r");
const writable = current?.includes("w") || permission.actionRaw.includes("w");
assignments.set(namespaceId, readable && writable ? "rw" : readable ? "r" : "w");
}
return [...assignments].map(([namespaceId, action]) => ({ namespaceId, action }));
}
export function parseNacosRawBody(text: string): unknown {
const trimmed = text.trim();
if (!trimmed) return undefined;
@@ -1,5 +1,14 @@
import type { NacosNamespaceInfo } from "@/types/nacos";
type NacosNamespaceAccessClient = {
nacosListNamespaces(connectionId: string): Promise<NacosNamespaceInfo[]>;
};
/** The backend applies account authorization or an explicitly configured namespace scope. */
export async function loadReadableNacosNamespaces(connectionId: string, client: NacosNamespaceAccessClient): Promise<NacosNamespaceInfo[]> {
return normalizeNacosNamespacesForDisplay(await client.nacosListNamespaces(connectionId));
}
/**
* Nacos 2/r-nacos may represent the default namespace with an empty ID, while
* Nacos 3 returns the concrete `public` ID. Older backend builds could expose
@@ -48,6 +48,10 @@ export type ActiveTabSidebarTarget =
type: "etcd-access-control";
connectionId: string;
}
| {
type: "nacos-access-control";
connectionId: string;
}
| {
type: "zookeeper-root";
connectionId: string;
@@ -161,6 +165,10 @@ export function activeTabSidebarTarget(tab: QueryTab | undefined | null): Active
return { type: "etcd-access-control", connectionId: tab.connectionId };
}
if (tab.mode === "nacos-access-control") {
return { type: "nacos-access-control", connectionId: tab.connectionId };
}
if (tab.mode === "zookeeper") {
return { type: "zookeeper-root", connectionId: tab.connectionId };
}
@@ -252,6 +260,10 @@ export function matchesTarget(node: TreeNode, target: ActiveTabSidebarTarget): b
return node.type === "etcd-access-control" && node.connectionId === target.connectionId;
}
if (target.type === "nacos-access-control") {
return node.type === "nacos-access-control" && node.connectionId === target.connectionId;
}
if (target.type === "zookeeper-root") {
return node.type === "zookeeper-root" && node.connectionId === target.connectionId;
}
@@ -15,12 +15,12 @@ function bestMatch(matchLabel: SidebarLabelMatcher, label: string, comment?: str
return best;
}
function normalizedLabel(node: TreeNode): string {
function normalizedLabel(node: TreeNode, resolveLabel?: (node: TreeNode) => string): string {
// Keep the original case. The matcher lowercases internally for comparison;
// preserving case here lets it tokenize camelCase labels ("camelCaseTable"
// -> "camel" | "Case" | "Table") instead of treating them as one lowercase
// blob.
return node.label;
return resolveLabel?.(node) ?? node.label;
}
function findNodePath(nodes: readonly TreeNode[], targetNodeId: string, ancestors: readonly TreeNode[] = []): readonly TreeNode[] | undefined {
@@ -53,10 +53,18 @@ export function resolveSidebarObjectSearchFilter(nodes: readonly TreeNode[], tar
return preservesSubtree ? "" : query;
}
export function filterSidebarTree(nodes: TreeNode[], query: string, collapsedIds: ReadonlySet<string>, searchableNodeTypes?: ReadonlySet<TreeNodeType>, options?: SidebarSearchMatcherOptions): TreeNode[] {
const matchLabel = query ? createSidebarLabelMatcher(query, options) : undefined;
export interface SidebarTreeSearchMatcherOptions extends SidebarSearchMatcherOptions {
resolveLabel?: (node: TreeNode) => string;
}
export function filterSidebarTree(nodes: TreeNode[], query: string, collapsedIds: ReadonlySet<string>, searchableNodeTypes?: ReadonlySet<TreeNodeType>, options?: SidebarTreeSearchMatcherOptions | ((node: TreeNode) => string)): TreeNode[] {
// Accept the pre-regex label resolver form as well as the options object so
// callers can use localized synthetic labels without losing regex matching.
const resolveLabel = typeof options === "function" ? options : options?.resolveLabel;
const matcherOptions = typeof options === "function" ? undefined : options;
const matchLabel = query ? createSidebarLabelMatcher(query, matcherOptions) : undefined;
if (!matchLabel && searchableNodeTypes === undefined) return nodes;
return filterSidebarTreeWithMatcher(nodes, matchLabel, collapsedIds, searchableNodeTypes);
return filterSidebarTreeWithMatcher(nodes, matchLabel, collapsedIds, searchableNodeTypes, resolveLabel);
}
export function reuseLiveSidebarTreeNodes(indexedNodes: TreeNode[], liveNodes: readonly TreeNode[]): TreeNode[] {
@@ -312,7 +320,7 @@ function preservedSearchChildren(node: TreeNode, collapsedIds: ReadonlySet<strin
return node.children.filter((child) => !hiddenSearchNodeTypes.has(child.type)).map((child) => applySearchCollapsedState(child, collapsedIds));
}
function filterSidebarTreeWithMatcher(nodes: TreeNode[], matchLabel: SidebarLabelMatcher | undefined, collapsedIds: ReadonlySet<string>, searchableNodeTypes?: ReadonlySet<TreeNodeType>): TreeNode[] {
function filterSidebarTreeWithMatcher(nodes: TreeNode[], matchLabel: SidebarLabelMatcher | undefined, collapsedIds: ReadonlySet<string>, searchableNodeTypes?: ReadonlySet<TreeNodeType>, resolveLabel?: (node: TreeNode) => string): TreeNode[] {
const filteredNodes: { node: TreeNode; score: number }[] = [];
for (const node of nodes) {
@@ -320,7 +328,7 @@ function filterSidebarTreeWithMatcher(nodes: TreeNode[], matchLabel: SidebarLabe
if (node.type === "object-browser" && node.hiddenChildren) {
const matches = node.hiddenChildren.flatMap((child) => {
if (searchableNodeTypes && !searchableNodeTypes.has(child.type)) return [];
const match = matchLabel?.(normalizedLabel(child));
const match = matchLabel?.(normalizedLabel(child, resolveLabel));
if (matchLabel && !match) return [];
return [{ node: child, score: match?.score ?? 0 }];
});
@@ -328,7 +336,7 @@ function filterSidebarTreeWithMatcher(nodes: TreeNode[], matchLabel: SidebarLabe
continue;
}
const label = normalizedLabel(node);
const label = normalizedLabel(node, resolveLabel);
const canSelfMatch = !searchableNodeTypes || searchableNodeTypes.has(node.type);
const selfMatch = canSelfMatch ? (matchLabel ? bestMatch(matchLabel, label, node.comment, node.searchAliases) : { score: 0 }) : null;
// Type-only filtering keeps matching rows and their ancestor path, but not
@@ -340,7 +348,7 @@ function filterSidebarTreeWithMatcher(nodes: TreeNode[], matchLabel: SidebarLabe
// Connection utility entries are synthetic navigation actions, not schema
// search results. Keep real loaded descendants for connection-name matches,
// but do not let those actions make a disconnected result look expanded.
const filteredChildren = preservesSubtree ? preservedSearchChildren(node, collapsedIds) : node.children ? filterSidebarTreeWithMatcher(node.children, matchLabel, collapsedIds, searchableNodeTypes) : undefined;
const filteredChildren = preservesSubtree ? preservedSearchChildren(node, collapsedIds) : node.children ? filterSidebarTreeWithMatcher(node.children, matchLabel, collapsedIds, searchableNodeTypes, resolveLabel) : undefined;
if (selfMatch || (filteredChildren && filteredChildren.length > 0)) {
if (!node.children || preservesTypeMatchedTable) {
@@ -20,6 +20,8 @@ const leafTypes: Set<TreeNodeType> = new Set([
"etcd-root",
"etcd-dashboard",
"etcd-access-control",
"nacos-namespace",
"nacos-access-control",
"zookeeper-root",
"consul-root",
"consul-overview",
@@ -16,6 +16,8 @@ const toggleLeafNodeTypes = new Set<TreeNodeType>([
"etcd-root",
"etcd-dashboard",
"etcd-access-control",
"nacos-namespace",
"nacos-access-control",
"zookeeper-root",
"consul-root",
"consul-overview",
@@ -31,12 +33,17 @@ const toggleLeafNodeTypes = new Set<TreeNodeType>([
// These are application entry points rather than database objects. They should
// always navigate on a single click, even when the user prefers double-click
// activation for ordinary tree objects.
const directNavigationTreeNodeTypes = new Set<TreeNodeType>(["consul-root", "consul-overview"]);
const directNavigationTreeNodeTypes = new Set<TreeNodeType>(["consul-root", "consul-overview", "nacos-namespace", "nacos-access-control"]);
const repeatableNavigationTreeNodeTypes = new Set<TreeNodeType>(["nacos-namespace", "nacos-access-control"]);
export function isDirectNavigationTreeNode(type: TreeNodeType): boolean {
return directNavigationTreeNodeTypes.has(type);
}
export function isRepeatableNavigationTreeNode(type: TreeNodeType): boolean {
return repeatableNavigationTreeNodeTypes.has(type);
}
export function shouldActivateTreeNodeOnSingleClick(type: TreeNodeType, activation: SidebarActivation = "single"): boolean {
return activation !== "double" || isDirectNavigationTreeNode(type);
}
@@ -33,6 +33,7 @@ export function getTreeNodeIconInfo(node: TreeNode): TreeNodeIconInfo | null {
return { icon: FolderOpen, colorClass: "text-sky-500" };
case "etcd-dashboard":
return { icon: Gauge, colorClass: "text-sky-500" };
case "nacos-access-control":
case "etcd-access-control":
return { icon: ShieldCheck, colorClass: "text-sky-500" };
case "zookeeper-root":
@@ -115,6 +115,10 @@ export function tabDisplayTitle(tab: QueryTab, t: Translate): string {
if (compact) return connectionDisplayName(tab.connectionId);
return `${connectionDisplayName(tab.connectionId)}@${t("tabs.etcdAccessControl")}`;
}
if (tab.mode === "nacos-access-control") {
if (compact) return connectionDisplayName(tab.connectionId);
return `${connectionDisplayName(tab.connectionId)}@${t("tabs.nacosAccessControl")}`;
}
if (tab.mode === "zookeeper") {
if (compact) return connectionDisplayName(tab.connectionId);
return `${connectionDisplayName(tab.connectionId)}@keys`;
@@ -440,6 +444,7 @@ export function tabModeLabel(tab: QueryTab, t: Translate): string {
if (tab.mode === "etcd") return t("tabs.etcd");
if (tab.mode === "etcd-dashboard") return t("tabs.etcdDashboard");
if (tab.mode === "etcd-access-control") return t("tabs.etcdAccessControl");
if (tab.mode === "nacos-access-control") return t("tabs.nacosAccessControl");
if (tab.mode === "zookeeper") return t("tabs.zookeeper");
if (tab.mode === "consul") return t("tabs.consul");
if (tab.mode === "consul-overview") return t("consul.ui.overview");
@@ -0,0 +1,102 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ConnectionConfig, TreeNode } from "@/types/database";
function installLocalStorage() {
const data = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: vi.fn((key: string) => data.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
removeItem: vi.fn((key: string) => data.delete(key)),
});
}
describe("connectionStore Nacos namespace access", () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
installLocalStorage();
setActivePinia(createPinia());
});
async function loadNacosTree(listUsersSupported: boolean, listRoleBindingsSupported = false) {
const connection = {
id: "nacos-bb",
name: "Nacos bb",
db_type: "nacos",
host: "127.0.0.1",
port: 8848,
username: "bb",
password: "secret",
database: "",
visible_databases: ["aa", "bb", "cc", ""],
} as ConnectionConfig;
// The backend has already removed `bb` and `public`, which the restricted
// account cannot read even when the server's raw list endpoint exposes them.
const readableNamespaces = ["aa", "cc"].map((namespace) => ({
namespace,
namespaceShowName: namespace || "public",
}));
const nacosListConfigs = vi.fn();
const nacosSidebarSnapshot = vi.fn().mockResolvedValue({
namespaces: readableNamespaces,
accessControl: {
listUsers: { supported: listUsersSupported },
listRoleBindings: { supported: listRoleBindingsSupported },
},
});
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
listInstalledAgents: vi.fn().mockResolvedValue([]),
loadSchemaCache: vi.fn().mockResolvedValue(null),
nacosListConfigs,
nacosSidebarSnapshot,
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const root: TreeNode = {
id: connection.id,
label: connection.name,
type: "connection",
connectionId: connection.id,
isExpanded: false,
children: [],
};
store.connections = [connection];
store.treeNodes = [root];
store.connectedIds.add(connection.id);
await store.loadNacosNamespaces(connection.id, { force: true });
return { root, store, nacosListConfigs, nacosSidebarSnapshot };
}
it("hides unreadable namespaces without adding per-namespace sidebar requests", async () => {
const { root, store, nacosListConfigs, nacosSidebarSnapshot } = await loadNacosTree(false);
expect(nacosListConfigs).not.toHaveBeenCalled();
expect(nacosSidebarSnapshot).toHaveBeenCalledTimes(1);
expect(root.children?.filter((node) => node.type === "nacos-namespace").map((node) => node.label)).toEqual(["aa", "cc"]);
expect(store.getSidebarVisibleFilterSummary("nacos-bb")).toEqual({ mode: "namespace", isActive: false, selected: 2, total: 2 });
expect(root.children?.some((node) => node.type === "nacos-access-control")).toBe(false);
});
it("shows access control when the current account can list users", async () => {
const { root } = await loadNacosTree(true);
expect(root.children?.find((node) => node.type === "nacos-access-control")?.label).toBe("nacos.accessControlSidebarLabel");
});
it("shows access control for a roles-only account", async () => {
const { root } = await loadNacosTree(false, true);
expect(root.children?.find((node) => node.type === "nacos-access-control")?.label).toBe("nacos.accessControlSidebarLabel");
});
});
@@ -67,6 +67,32 @@ describe("connectionStore timeout recovery", () => {
expect(store.connectedIds.has(connection.id)).toBe(true);
}, 10_000);
it("does not block pure navigation on a connected health check", async () => {
const checkConnectionHealth = vi.fn(() => new Promise(() => undefined));
const connectDb = vi.fn().mockResolvedValue(undefined);
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth,
connectDb,
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const connection = postgresConnection();
store.connections = [connection];
store.connectedIds.add(connection.id);
await store.ensureConnected(connection.id, { verifyHealth: false });
expect(checkConnectionHealth).not.toHaveBeenCalled();
expect(connectDb).not.toHaveBeenCalled();
expect(store.connectedIds.has(connection.id)).toBe(true);
});
it("normalizes missing keepalive interval to 30 seconds", async () => {
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
+22 -6
View File
@@ -3370,8 +3370,12 @@ export const useConnectionStore = defineStore("connection", () => {
invalidateObjectBrowserRowsCache({ connectionId, database });
}
async function ensureConnected(connectionId: string, options: { activate?: boolean } = {}) {
async function ensureConnected(connectionId: string, options: { activate?: boolean; verifyHealth?: boolean } = {}) {
if (connectedIds.value.has(connectionId)) {
// Pure navigation can safely trust the existing connected state. Its
// destination will perform the real API request, while blocking here on
// a health probe makes an otherwise local tab switch take up to 5s.
if (options.verifyHealth === false) return;
if (hasRecentConnectionHealthCheck(connectionId)) return;
// Optimistic: verify backend pool is actually healthy
try {
@@ -4067,7 +4071,8 @@ export const useConnectionStore = defineStore("connection", () => {
load = reclaimTreeNodeLoad(load, node);
if (useCachedChildren(node, options, load)) return;
const namespaces = normalizeNacosNamespacesForDisplay(await api.nacosListNamespaces(connectionId));
const sidebarSnapshot = await api.nacosSidebarSnapshot(connectionId);
const namespaces = normalizeNacosNamespacesForDisplay(sidebarSnapshot.namespaces);
const visibleNamespaces = filterNacosNamespacesForSidebar(namespaces, getConfig(connectionId)?.visible_databases);
const sorted = [...visibleNamespaces].sort((left, right) => {
const leftLabel = left.namespaceShowName || left.namespace || "public";
@@ -4080,9 +4085,8 @@ export const useConnectionStore = defineStore("connection", () => {
connectionId,
namespaces.map((namespace) => namespace.namespace),
);
setChildren(
targetNode,
sorted.map((namespace) => {
const children: TreeNode[] = [
...sorted.map((namespace) => {
const value = namespace.namespace || "";
const label = namespace.namespaceShowName || value || "public";
return {
@@ -4096,7 +4100,19 @@ export const useConnectionStore = defineStore("connection", () => {
objectCount: namespace.configCount,
};
}),
);
];
if (sidebarSnapshot.accessControl.listUsers.supported === true || sidebarSnapshot.accessControl.listRoleBindings.supported === true) {
children.push({
id: `${connectionId}:nacos-access-control`,
label: "nacos.accessControlSidebarLabel",
type: "nacos-access-control" as const,
connectionId,
database: "",
isExpanded: false,
children: [],
});
}
setChildren(targetNode, children);
targetNode.isExpanded = true;
} catch (e) {
recordMetadataLoadError(connectionId, e, load);
+2
View File
@@ -939,6 +939,7 @@ export type TreeNodeType =
| "redis-db"
| "mq-tenant"
| "nacos-namespace"
| "nacos-access-control"
| "etcd-root"
| "etcd-dashboard"
| "etcd-access-control"
@@ -1182,6 +1183,7 @@ export interface QueryTab {
| "mqtt"
| "nacos"
| "nacos-dashboard"
| "nacos-access-control"
| "databases"
| "objects"
| "structure"
+171 -1
View File
@@ -8,6 +8,26 @@ export interface NacosCapabilities {
/** Naming capabilities are intentionally granular: servers such as r-nacos
* can expose discovery reads without supporting the official write APIs. */
serviceManagement?: NacosServiceCapabilities;
accessControl?: NacosAccessControlCapabilities;
}
export type NacosAccessControlMode = "unavailable" | "roleBindings" | "embeddedRoles";
export interface NacosAccessControlCapabilities {
mode: NacosAccessControlMode;
listUsers: NacosOperationCapability;
createUser: NacosOperationCapability;
updateUser: NacosOperationCapability;
deleteUser: NacosOperationCapability;
listRoleBindings: NacosOperationCapability;
assignRole: NacosOperationCapability;
removeRole: NacosOperationCapability;
listPermissions: NacosOperationCapability;
grantPermission: NacosOperationCapability;
revokePermission: NacosOperationCapability;
/** All read endpoints required by the directory-detail workspace are available. */
enhancedWorkspace: boolean;
supportsNamespacePrivileges: boolean;
}
export interface NacosServiceCapabilities {
@@ -22,7 +42,7 @@ export interface NacosServiceCapabilities {
deregisterInstance: NacosOperationCapability;
}
export type NacosCapabilityReason = "implementationReadOnly" | "versionUnsupported" | "endpointUnavailable" | "notVerified" | "connectionReadOnly";
export type NacosCapabilityReason = "implementationReadOnly" | "versionUnsupported" | "endpointUnavailable" | "notVerified" | "connectionReadOnly" | "permissionDenied";
export interface NacosOperationCapability {
supported: boolean;
@@ -44,6 +64,149 @@ export interface NacosRNacosConsoleCaptcha {
image?: string;
}
export interface NacosNamespacePrivilege {
enabled: boolean;
whitelistIsAll: boolean;
whitelist: string[];
blacklistIsAll: boolean;
blacklist: string[];
}
export interface NacosUserQuery {
username?: string;
pageNo?: number;
pageSize?: number;
}
export interface NacosUserInfo {
username: string;
nickname?: string;
enabled?: boolean;
roles: string[];
namespacePrivilege?: NacosNamespacePrivilege;
source?: string;
}
export interface NacosUserList {
pageNo: number;
pageSize: number;
totalCount: number;
items: NacosUserInfo[];
}
export interface NacosUserCreate {
username: string;
password: string;
nickname?: string;
enabled?: boolean;
roles: string[];
namespacePrivilege?: NacosNamespacePrivilege;
}
export interface NacosUserUpdate {
username: string;
password?: string;
nickname?: string;
enabled?: boolean;
roles?: string[];
namespacePrivilege?: NacosNamespacePrivilege;
}
export interface NacosRoleQuery {
username?: string;
role?: string;
pageNo?: number;
pageSize?: number;
}
export interface NacosRoleBinding {
username: string;
role: string;
}
export interface NacosRoleList {
pageNo: number;
pageSize: number;
totalCount: number;
items: NacosRoleBinding[];
}
export type NacosPermissionScopeKind = "namespace" | "global" | "custom" | "unknown";
export interface NacosPermissionScope {
kind: NacosPermissionScopeKind;
namespaceId?: string;
}
export interface NacosPermissionInfo {
role: string;
resourceRaw: string;
actionRaw: string;
parsedScope?: NacosPermissionScope;
}
export interface NacosRoleSummary {
role: string;
memberCount: number;
permissionCount: number;
complete: boolean;
administrator: boolean;
}
export interface NacosAccessControlSnapshot {
users: NacosUserInfo[];
roleBindings: NacosRoleBinding[];
permissions: NacosPermissionInfo[];
roles: NacosRoleSummary[];
namespaces: NacosNamespaceInfo[];
currentUsername?: string;
}
export interface NacosNewUserDraft {
username: string;
password: string;
}
export interface NacosPermissionDraft {
namespaceIds: string[];
action: "r" | "w" | "rw";
}
export type NacosAccessOperationRequest =
| { kind: "createUser"; username: string; password: string; roles: string[]; confirmation?: string }
| { kind: "createRole"; role: string; members: string[]; newUsers: NacosNewUserDraft[]; permissions: NacosPermissionDraft[]; confirmation?: string }
| { kind: "updateUserRoles"; username: string; roles: string[]; confirmation?: string }
| { kind: "updateRole"; role: string; members: string[]; newUsers: NacosNewUserDraft[]; permissions: NacosPermissionDraft[]; confirmation?: string }
| { kind: "deleteUser"; username: string; confirmation?: string }
| { kind: "deleteRole"; role: string; confirmation?: string }
| { kind: "revokePermission"; permission: NacosPermissionInfo; confirmation?: string };
export type NacosAccessOperationStatus = "running" | "partial" | "succeeded" | "failed" | "undoing" | "undone";
export type NacosAccessOperationStepStatus = "pending" | "running" | "succeeded" | "failed" | "skipped" | "compensated";
export interface NacosAccessOperationStep {
id: string;
action: string;
target: string;
status: NacosAccessOperationStepStatus;
retryable: boolean;
needsPassword: boolean;
message?: string;
}
export interface NacosAccessOperationResult {
operationId: string;
status: NacosAccessOperationStatus;
steps: NacosAccessOperationStep[];
canRetry: boolean;
canUndo: boolean;
}
export interface NacosAccessOperationRetry {
operationId: string;
credentials: NacosNewUserDraft[];
}
export interface NacosNamespaceInfo {
namespace: string;
namespaceShowName: string;
@@ -53,6 +216,11 @@ export interface NacosNamespaceInfo {
namespaceType?: number;
}
export interface NacosNamespaceSidebarSnapshot {
namespaces: NacosNamespaceInfo[];
accessControl: NacosAccessControlCapabilities;
}
export interface NacosNamespaceCreate {
namespaceId?: string;
namespaceName: string;
@@ -81,6 +249,8 @@ export interface NacosAdminConfig {
versionMode?: NacosVersionMode;
serverAddr: string;
contextPath?: string;
/** Namespace IDs used when an official Nacos ordinary user cannot enumerate namespaces or authorization data. */
managedNamespaces?: string[];
rnacosConsoleAddr?: string;
/** Undefined keeps the legacy behaviour: history is enabled when a console address exists. */
rnacosHistoryEnabled?: boolean;