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