feat(nacos): add access control and namespace administration

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-08-21 01:15:46 +08:00
1 parent 30f75d8388
commit 7fa94152a8
59 files changed
+4563 -479

No files matched your search

@@ -19,7 +19,7 @@ import type { InfluxDbExternalConfig, InfluxDbVersion } from "@/types/influxdb";
import type { VictoriaMetricsExternalConfig } from "@/types/victoriametrics";
import type { MqAdminConfig, MqAuth, MqSystemKind } from "@/types/mq";
import type { MqttConnectionConfig } from "@/types/mqtt";
import type { NacosAdminConfig, NacosAuthConfig, NacosImplementation, NacosMetricsMode, NacosNamespaceInfo, NacosRNacosConsoleAuth, NacosVersionMode } from "@/types/nacos";
import type { NacosAdminConfig, NacosApiPlane, NacosAuthConfig, NacosImplementation, NacosMetricsMode, NacosNamespaceInfo, NacosRNacosConsoleAuth, NacosVersionMode } from "@/types/nacos";
import { CONNECTION_ATTEMPT_CANCELLED_MESSAGE, useConnectionStore } from "@/stores/connectionStore";
import { useTunnelProfileStore } from "@/stores/tunnelProfileStore";
import { detachTunnelProfileLayer, tunnelProfileReferenceLayer, tunnelProfileSummary } from "@/lib/connection/tunnelProfiles";
@@ -298,6 +298,11 @@ const visibleNacosNamespaces = ref<NacosNamespaceInfo[]>([]);
const visibleNacosNamespaceSelection = ref<Set<string>>(new Set());
const visibleNacosNamespaceSearchText = ref("");
const visibleNacosNamespaceError = ref("");
const visibleNacosNamespaceListingPermissionDenied = ref(false);
const isResolvingManualNacosNamespaces = ref(false);
const visibleNacosNamespaceAccessMode = ref<"automatic" | "manual">("automatic");
const nacosDynamicAllNamespaces = ref(false);
const visibleNacosNamespaceDynamicAllSupported = ref(false);
const showProductionDatabasesDialog = ref(false);
const isLoadingProductionDatabases = ref(false);
const productionDatabaseNames = ref<string[]>([]);
@@ -883,8 +888,9 @@ const nacosImplementation = ref<NacosImplementation>("nacos");
// 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 nacosApiPlane = ref<NacosApiPlane>("admin");
const nacosServerAddr = ref("");
const nacosOrdinaryAccount = ref(false);
const nacosContextPath = ref("");
const nacosManagedNamespacesText = ref("");
const nacosRNacosConsoleAddr = ref("");
const nacosHistoryEnabled = ref(false);
@@ -898,6 +904,11 @@ const nacosTlsSkipVerify = ref(false);
const nacosMetricsMode = ref<NacosMetricsMode>("auto");
const nacosMetricsUrl = ref("");
const nacosPageSize = ref(20);
let nacosScopeFingerprintBaseline = "";
function currentNacosScopeFingerprint(): string {
return JSON.stringify([nacosImplementation.value, nacosVersionMode.value, nacosApiPlane.value, nacosServerAddr.value.trim().replace(/\/+$/, ""), nacosContextPath.value.trim(), nacosAuthKind.value, nacosUsername.value.trim()]);
}
type ConsulConsistency = "default" | "stale" | "consistent";
const consulServerAddr = ref("http://127.0.0.1:8500");
const consulDatacenter = ref("");
@@ -935,15 +946,25 @@ const mqttConnectTimeoutSecs = ref(30);
const mqttMaxPacketSizeBytes = ref(16 * 1024 * 1024);
const mqttSavedTopics = ref<MqttConnectionConfig["savedTopics"]>([]);
const nacosPrimaryAddressPlaceholder = computed(() => {
if (nacosImplementation.value === "nacos" && nacosVersionMode.value === "v3" && nacosApiPlane.value === "console") {
return "http://127.0.0.1:8080";
}
return "http://127.0.0.1:8848/nacos";
});
const nacosServiceAddressHint = computed(() => {
if (nacosImplementation.value === "nacos" && nacosVersionMode.value === "v3" && nacosApiPlane.value === "console") {
return t("nacos.nacosConsoleServiceAddressHint");
}
return t("nacos.nacosServiceAddressHint");
});
const nacosV3AdminEndpointWarning = computed(() => {
if (nacosImplementation.value !== "nacos" || nacosVersionMode.value !== "v3" || !nacosServerAddr.value.trim()) return "";
try {
const url = new URL(nacosServerAddr.value.trim());
if (url.port === "8080") {
if (nacosApiPlane.value === "admin" && url.port === "8080") {
return t("nacos.nacosV3ConsolePortWarning");
}
if (nacosApiPlane.value === "console" && url.port === "8848") return t("nacos.nacosV3AdminPortWarning");
} catch {
// The normal form validation will report an invalid URL on save.
}
@@ -971,6 +992,7 @@ function selectNacosConnectionProfile(profile: NacosConnectionProfile) {
}
nacosImplementation.value = "nacos";
nacosVersionMode.value = profile;
if (profile !== "v3") nacosApiPlane.value = "admin";
}
watch(nacosImplementation, (implementation) => {
@@ -1459,11 +1481,13 @@ function resetNacosFields(config?: Partial<NacosAdminConfig>) {
// Saved `auto` profiles are legacy records. The form always saves an
// explicit selection and no longer relies on a separate Console address.
nacosVersionMode.value = config?.versionMode === "v3" ? "v3" : "v2";
nacosApiPlane.value = config?.versionMode === "v3" ? config?.apiPlane || "admin" : "admin";
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;
nacosServerAddr.value = serverAddr;
nacosContextPath.value = contextPath;
nacosManagedNamespacesText.value = (config?.managedNamespaces || []).join("\n");
nacosDynamicAllNamespaces.value = !!config && !config.managedNamespaces?.length && !Array.isArray(form.value.visible_databases);
nacosRNacosConsoleAddr.value = config?.rnacosConsoleAddr?.trim() || "";
nacosHistoryEnabled.value = config?.rnacosHistoryEnabled ?? !!config?.rnacosConsoleAddr;
const consoleAuth = config?.rnacosConsoleAuth || { kind: "inherit" };
@@ -1478,6 +1502,7 @@ function resetNacosFields(config?: Partial<NacosAdminConfig>) {
nacosAuthKind.value = auth.kind || "none";
nacosUsername.value = auth.username || "nacos";
nacosPassword.value = auth.password || "";
nacosScopeFingerprintBaseline = currentNacosScopeFingerprint();
}
function hydrateNacosFields(value: unknown) {
@@ -1805,7 +1830,8 @@ function buildNacosAdminConfig(): NacosAdminConfig {
const normalized = normalizeNacosEndpoint(primaryAddress, {
implementation: nacosImplementation.value,
versionMode: nacosVersionMode.value,
contextPath: undefined,
apiPlane: nacosApiPlane.value,
contextPath: nacosContextPath.value,
});
if (nacosImplementation.value === "rnacos" && normalized.warnings.length) {
throw new Error(t("connection.nacosRNacosOpenApiRequired"));
@@ -1815,11 +1841,7 @@ function buildNacosAdminConfig(): NacosAdminConfig {
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"));
}
const managedNamespaces = nacosImplementation.value === "nacos" && nacosAuthKind.value === "usernamePassword" ? parseNacosManagedNamespaces(nacosManagedNamespacesText.value) : [];
let metricsUrl: string | undefined;
if (nacosMetricsMode.value === "custom") {
try {
@@ -1843,6 +1865,7 @@ function buildNacosAdminConfig(): NacosAdminConfig {
return {
implementation: nacosImplementation.value,
versionMode: nacosImplementation.value === "nacos" ? nacosVersionMode.value : undefined,
apiPlane: nacosImplementation.value === "nacos" && nacosVersionMode.value === "v3" ? nacosApiPlane.value : undefined,
serverAddr: normalized.serverAddr,
contextPath: normalized.contextPath || undefined,
managedNamespaces: managedNamespaces.length ? managedNamespaces : undefined,
@@ -3461,6 +3484,24 @@ const shouldShowAgentDriverInstallHint = computed(() => showAgentDriverInstallHi
const h2DriverMissing = computed(() => form.value.db_type === "h2" && isH2FileMode.value && agentDrivers.value.find((d) => d.db_type === "h2")?.installed !== true);
const agentDriverFocus = computed<DriverStoreFocus>(() => ({ target: "driver", driver: agentDriverInstallKey(form.value.db_type, form.value.driver_profile) }));
const canChooseVisibleNacosNamespaces = computed(() => form.value.db_type === "nacos");
const isNacosV3AdminPlane = computed(() => nacosImplementation.value === "nacos" && nacosVersionMode.value === "v3" && nacosApiPlane.value === "admin");
const isNacosV3ConsolePlane = computed(() => nacosImplementation.value === "nacos" && nacosVersionMode.value === "v3" && nacosApiPlane.value === "console");
const canDetectNacosNamespaceAccess = computed(() => form.value.db_type === "nacos" && nacosImplementation.value === "nacos" && nacosAuthKind.value === "usernamePassword");
const nacosManualNamespaceLabelKey = computed(() => (isNacosV3AdminPlane.value ? "nacos.nacosManagedNamespaces" : "nacos.nacosManagedNamespacesNameOrId"));
const nacosManualNamespacePlaceholderKey = computed(() => (isNacosV3AdminPlane.value ? "nacos.nacosManagedNamespacesIdPlaceholder" : "nacos.nacosManagedNamespacesPlaceholder"));
const nacosManualNamespaceHintKey = computed(() => {
if (isNacosV3AdminPlane.value) return "nacos.nacosV3AdminManagedNamespacesHint";
if (isNacosV3ConsolePlane.value) return "nacos.nacosV3ConsoleManagedNamespacesHint";
return "nacos.nacosManagedNamespacesHint";
});
const nacosNamespacePickerTitleKey = computed(() => (canDetectNacosNamespaceAccess.value ? "nacos.nacosDetectAccessibleNamespaces" : "nacos.nacosVisibleNamespacesTitle"));
const nacosNamespacePickerDescriptionKey = computed(() => (canDetectNacosNamespaceAccess.value ? "nacos.nacosDetectAccessibleNamespacesHint" : "nacos.nacosVisibleNamespacesDescription"));
function hasNacosNamespaceScopeForSave(): boolean {
if (!canDetectNacosNamespaceAccess.value) return true;
if (nacosDynamicAllNamespaces.value) return true;
if (parseNacosManagedNamespaces(nacosManagedNamespacesText.value).length > 0) return true;
return Array.isArray(form.value.visible_databases) && form.value.visible_databases.length > 0;
}
const canChooseVisibleDatabases = computed(() => !canChooseVisibleNacosNamespaces.value && connectionCanChooseVisibleDatabases(form.value));
const visibleFilterUsesSchemas = computed(() => connectionUsesVisibleSchemaFilter(form.value));
const hasVisibleDatabaseFilter = computed(() => Array.isArray(form.value.visible_databases));
@@ -3494,7 +3535,7 @@ const filteredVisibleNacosNamespaces = computed(() => {
});
});
const visibleNacosNamespaceSelectedCount = computed(() => visibleNacosNamespaceSelection.value.size);
const visibleNacosNamespaceCanSave = computed(() => visibleNacosNamespaceSelection.value.size > 0);
const visibleNacosNamespaceCanSave = computed(() => (visibleNacosNamespaceAccessMode.value === "manual" ? parseNacosManagedNamespaces(nacosManagedNamespacesText.value).length > 0 : visibleNacosNamespaceSelection.value.size > 0));
const filteredProductionDatabaseNames = computed(() => {
const query = productionDatabaseSearchText.value.trim().toLowerCase();
if (!query) return productionDatabaseNames.value;
@@ -4632,6 +4673,10 @@ function resetVisibleNacosNamespaceDraftState() {
visibleNacosNamespaceSelection.value = new Set();
visibleNacosNamespaceSearchText.value = "";
visibleNacosNamespaceError.value = "";
visibleNacosNamespaceListingPermissionDenied.value = false;
isResolvingManualNacosNamespaces.value = false;
visibleNacosNamespaceAccessMode.value = "automatic";
visibleNacosNamespaceDynamicAllSupported.value = false;
}
function resetProductionDatabaseDraftState() {
@@ -4715,24 +4760,99 @@ function normalizeVisibleNacosNamespaceSelection(selected: Iterable<string>, nam
return normalizeNacosNamespaceSelection(selected, namespaces);
}
function normalizeManualNacosNamespaceNames(namespaces: string[], availableNamespaces: NacosNamespaceInfo[]): string[] {
const namespaceIds = new Map(availableNamespaces.map((namespace) => [nacosNamespaceIdentity(namespace.namespace), namespace.namespace]));
const namespaceNames = new Map<string, string[]>();
for (const namespace of availableNamespaces) {
const name = nacosNamespaceLabel(namespace).trim();
if (!name) continue;
namespaceNames.set(name, [...(namespaceNames.get(name) || []), namespace.namespace]);
}
const normalized: string[] = [];
const seen = new Set<string>();
for (const namespace of namespaces) {
const namespaceId = namespaceIds.get(nacosNamespaceIdentity(namespace));
const matchingNames = namespaceNames.get(namespace.trim()) || [];
// Display names are only safe to convert when the server returned one
// exact match. Keep unknown or ambiguous values intact so the regular
// permission check can report them instead of targeting a wrong namespace.
const value = namespaceId ?? (matchingNames.length === 1 ? matchingNames[0] : namespace);
const identity = nacosNamespaceIdentity(value);
if (!seen.has(identity)) {
seen.add(identity);
normalized.push(value);
}
}
return normalized;
}
async function resolveManualNacosNamespaceNames(namespaces: string[]): Promise<string[]> {
const draftId = buildDraftVisibleDatabasesConnectionId(uuid());
try {
const submittedConfig = connectionConfigForSubmit(draftId);
const draftConfig = {
...submittedConfig,
// Resolve display names against the authenticated directory rather than
// passing them as namespace IDs to the configuration and naming APIs.
external_config: submittedConfig.db_type === "nacos" ? { ...(submittedConfig.external_config as NacosAdminConfig), managedNamespaces: undefined } : submittedConfig.external_config,
id: draftId,
one_time: true,
};
await api.connectDb(draftConfig);
return normalizeManualNacosNamespaceNames(namespaces, await loadReadableNacosNamespaces(draftId, api));
} catch {
// Some Nacos deployments do not expose a readable namespace directory to
// ordinary users. Preserve manually entered IDs for the normal test path.
return namespaces;
} finally {
await api.disconnectDb(draftId).catch(() => undefined);
}
}
function isNacosNamespaceListingPermissionError(error: unknown): boolean {
const message = errorMessage(error);
if (/NACOS_ERROR\[(?:v3ManagedNamespacesRequired|managedNamespacesRequired)\]/.test(message)) return true;
return /\/v3\/(?:admin|console)\/core\/namespace\/list/.test(message) && /\b403\b/.test(message) && /authorization failed/i.test(message);
}
async function openVisibleNacosNamespacesPicker() {
if (!ensureConnectionHostResolvedFromUrl()) return;
if (!canChooseVisibleNacosNamespaces.value || isLoadingVisibleNacosNamespaces.value) return;
isLoadingVisibleNacosNamespaces.value = true;
visibleNacosNamespaceError.value = "";
visibleNacosNamespaceListingPermissionDenied.value = false;
visibleNacosNamespaceDynamicAllSupported.value = false;
visibleNacosNamespaceSearchText.value = "";
visibleNacosNamespaceAccessMode.value = "automatic";
const draftId = buildDraftVisibleDatabasesConnectionId(uuid());
try {
const submittedConfig = connectionConfigForSubmit(draftId);
const draftConfig = {
...connectionConfigForSubmit(draftId),
...submittedConfig,
// Automatic detection must not inherit a previously saved manual scope.
// Otherwise it would only rediscover that old subset instead of checking
// what the currently configured account can really access.
external_config: canDetectNacosNamespaceAccess.value && submittedConfig.db_type === "nacos" ? { ...(submittedConfig.external_config as NacosAdminConfig), managedNamespaces: undefined } : submittedConfig.external_config,
visible_databases: undefined,
id: draftId,
one_time: true,
};
await api.connectDb(draftConfig);
const namespaces = await loadReadableNacosNamespaces(draftId, api);
visibleNacosNamespaces.value = [...namespaces].sort((left, right) => nacosNamespaceLabel(left).localeCompare(nacosNamespaceLabel(right)));
try {
const sidebarSnapshot = await api.nacosSidebarSnapshot(draftId);
const readableIds = normalizeVisibleNacosNamespaceSelection(visibleNacosNamespaces.value.map(nacosNamespaceValue), visibleNacosNamespaces.value);
const sidebarIds = normalizeVisibleNacosNamespaceSelection(sidebarSnapshot.namespaces.map(nacosNamespaceValue), visibleNacosNamespaces.value);
const readableSet = new Set(readableIds.map(nacosNamespaceIdentity));
const sidebarSet = new Set(sidebarIds.map(nacosNamespaceIdentity));
visibleNacosNamespaceDynamicAllSupported.value = readableSet.size === sidebarSet.size && [...readableSet].every((namespace) => sidebarSet.has(namespace));
} catch {
visibleNacosNamespaceDynamicAllSupported.value = false;
}
const configured = form.value.visible_databases;
const initialSelection = Array.isArray(configured) ? normalizeVisibleNacosNamespaceSelection(configured, visibleNacosNamespaces.value) : visibleNacosNamespaces.value.map(nacosNamespaceValue);
visibleNacosNamespaceSelection.value = new Set(initialSelection);
@@ -4740,7 +4860,8 @@ async function openVisibleNacosNamespacesPicker() {
} catch (e: any) {
visibleNacosNamespaces.value = [];
visibleNacosNamespaceSelection.value = new Set();
visibleNacosNamespaceError.value = errorMessage(e);
visibleNacosNamespaceListingPermissionDenied.value = isNacosNamespaceListingPermissionError(e);
visibleNacosNamespaceError.value = visibleNacosNamespaceListingPermissionDenied.value ? t("nacos.nacosManagedNamespacesRequired") : errorMessage(e);
testResult.value = { ok: false, message: visibleNacosNamespaceError.value };
showVisibleNacosNamespacesDialog.value = true;
} finally {
@@ -4765,13 +4886,31 @@ function clearVisibleNacosNamespaceSelection() {
}
function showAllVisibleNacosNamespaces() {
nacosDynamicAllNamespaces.value = true;
nacosManagedNamespacesText.value = "";
form.value.visible_databases = undefined;
resetVisibleNacosNamespaceDraftState();
}
function saveVisibleNacosNamespaceSelection() {
async function saveVisibleNacosNamespaceSelection() {
if (!visibleNacosNamespaceCanSave.value) return;
form.value.visible_databases = normalizeVisibleNacosNamespaceSelection(visibleNacosNamespaceSelection.value, visibleNacosNamespaces.value);
if (visibleNacosNamespaceAccessMode.value === "manual") {
const manualNamespaces = parseNacosManagedNamespaces(nacosManagedNamespacesText.value);
isResolvingManualNacosNamespaces.value = true;
const resolvedNamespaces = isNacosV3AdminPlane.value ? manualNamespaces : await resolveManualNacosNamespaceNames(manualNamespaces);
isResolvingManualNacosNamespaces.value = false;
nacosManagedNamespacesText.value = resolvedNamespaces.join("\n");
form.value.visible_databases = resolvedNamespaces;
nacosDynamicAllNamespaces.value = false;
} else {
nacosManagedNamespacesText.value = "";
const selected = normalizeVisibleNacosNamespaceSelection(visibleNacosNamespaceSelection.value, visibleNacosNamespaces.value);
const all = normalizeVisibleNacosNamespaceSelection(visibleNacosNamespaces.value.map(nacosNamespaceValue), visibleNacosNamespaces.value);
const selectsEntireReadableList = selected.length === all.length && selected.every((namespace, index) => namespace === all[index]);
const useDynamicAll = selectsEntireReadableList && visibleNacosNamespaceDynamicAllSupported.value;
form.value.visible_databases = useDynamicAll ? undefined : selected;
nacosDynamicAllNamespaces.value = useDynamicAll;
}
showVisibleNacosNamespacesDialog.value = false;
}
@@ -5214,6 +5353,26 @@ watch(
},
);
watch(
() => currentNacosScopeFingerprint(),
(current) => {
if (form.value.db_type !== "nacos") {
nacosScopeFingerprintBaseline = current;
return;
}
if (!nacosScopeFingerprintBaseline || current === nacosScopeFingerprintBaseline) {
nacosScopeFingerprintBaseline = current;
return;
}
nacosScopeFingerprintBaseline = current;
nacosManagedNamespacesText.value = "";
nacosDynamicAllNamespaces.value = false;
form.value.visible_databases = undefined;
resetVisibleNacosNamespaceDraftState();
resetTestState();
},
);
watch(visibleDatabaseShowSystem, (show) => {
if (show) return;
const connection = connectionConfigSnapshotForVisibleDatabases();
@@ -5404,6 +5563,11 @@ async function persistConnectionNoteVisibilityDraft() {
async function save() {
if (!ensureConnectionHostResolvedFromUrl()) return;
if (isSaving.value) return;
if (!hasNacosNamespaceScopeForSave()) {
testResult.value = null;
await openVisibleNacosNamespacesPicker();
return;
}
const databaseInfoForSave = visibleTestDatabaseInfo.value ?? visibleSavedDatabaseInfo.value;
isSaving.value = true;
let connectionSaved = false;
@@ -6619,11 +6783,28 @@ function openExternalUrl(url: string) {
<section data-nacos-endpoint-section class="rounded-lg border p-4">
<div class="grid gap-4">
<div v-if="nacosImplementation === 'nacos' && nacosVersionMode === 'v3'" class="grid gap-1.5">
<Label>{{ t("nacos.nacosApiPlane") }}</Label>
<div class="grid grid-cols-2 gap-1 rounded-md border bg-muted/20 p-1">
<button
v-for="plane in ['admin', 'console'] as NacosApiPlane[]"
:key="plane"
type="button"
class="min-w-0 rounded px-3 py-2 text-left transition-colors"
:class="nacosApiPlane === plane ? 'bg-background shadow-sm' : 'text-muted-foreground hover:text-foreground'"
:aria-pressed="nacosApiPlane === plane"
@click="nacosApiPlane = plane"
>
<span class="block text-sm font-medium">{{ t(`nacos.nacosApiPlane${plane === "admin" ? "Admin" : "Console"}`) }}</span>
<span class="mt-0.5 block text-xs leading-4">{{ t(`nacos.nacosApiPlane${plane === "admin" ? "Admin" : "Console"}Hint`) }}</span>
</button>
</div>
</div>
<div class="grid gap-1.5">
<Label>{{ t("nacos.nacosServiceAddress") }}</Label>
<Input v-model="nacosServerAddr" :placeholder="nacosPrimaryAddressPlaceholder" />
<p class="text-xs leading-5 text-muted-foreground">
<template>{{ t("nacos.nacosServiceAddressHint") }}</template>
<template>{{ nacosServiceAddressHint }}</template>
</p>
</div>
<p v-if="nacosV3AdminEndpointWarning" class="rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs leading-5 text-amber-700 dark:text-amber-400">
@@ -6655,29 +6836,12 @@ 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>
<p v-if="isNacosV3AdminPlane && nacosAuthKind === 'usernamePassword'" class="mt-4 border-t pt-4 text-xs leading-5 text-muted-foreground">
{{ t("nacos.nacosV3AdminNamespaceScopeHint") }}
</p>
<p v-else-if="nacosImplementation === 'rnacos' && nacosAuthKind === 'usernamePassword'" class="mt-4 border-t pt-4 text-xs leading-5 text-muted-foreground">
{{ t("nacos.rnacosNamespaceAccessScopeHint") }}
</p>
</section>
<section data-nacos-advanced-hint class="flex items-start gap-3 rounded-lg border border-dashed bg-muted/20 px-4 py-3">
@@ -8106,6 +8270,17 @@ function openExternalUrl(url: string) {
<p class="mt-0.5 text-xs leading-5 text-muted-foreground">{{ t("nacos.nacosAdvancedDescription") }}</p>
</div>
<div class="grid gap-5 p-4">
<div class="grid gap-1.5">
<div class="flex items-center justify-between gap-3">
<Label>{{ t("connection.nacosContextPath") }}</Label>
<Button v-if="nacosContextPath" type="button" variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="nacosContextPath = ''">
{{ t("connection.nacosContextPathRestoreAuto") }}
</Button>
</div>
<Input v-model="nacosContextPath" :placeholder="t('connection.nacosContextPathPlaceholder')" />
<p class="text-[11px] leading-4 text-muted-foreground">{{ t("nacos.nacosContextPathHint") }}</p>
</div>
<div class="grid gap-1.5">
<Label>{{ t("connection.nacosPageSize") }}</Label>
<Input v-model.number="nacosPageSize" type="number" min="1" max="500" />
@@ -8721,7 +8896,7 @@ function openExternalUrl(url: string) {
<Button v-if="canChooseVisibleNacosNamespaces" variant="outline" class="shrink-0" :disabled="isTesting || isSaving || isLoadingVisibleNacosNamespaces || !hasRequiredConnectionTarget" @click="openVisibleNacosNamespacesPicker">
<Loader2 v-if="isLoadingVisibleNacosNamespaces" class="mr-1.5 h-4 w-4 animate-spin" />
<ListFilter v-else class="mr-1.5 h-4 w-4" />
{{ t("nacos.nacosVisibleNamespacesTitle") }}
{{ t(nacosNamespacePickerTitleKey) }}
</Button>
<Button v-else-if="canChooseVisibleDatabases" variant="outline" class="shrink-0" :disabled="isTesting || isSaving || isLoadingVisibleDatabases || !hasRequiredConnectionTarget" @click="openVisibleDatabasesPicker">
<Loader2 v-if="isLoadingVisibleDatabases" class="mr-1.5 h-4 w-4 animate-spin" />
@@ -8817,51 +8992,89 @@ function openExternalUrl(url: string) {
<Dialog v-model:open="showVisibleNacosNamespacesDialog">
<DialogContent class="sm:max-w-[460px]" @interact-outside.prevent @escape-key-down.prevent>
<DialogHeader>
<DialogTitle>{{ t("nacos.nacosVisibleNamespacesTitle") }}</DialogTitle>
<p class="text-sm text-muted-foreground">{{ t("nacos.nacosVisibleNamespacesDescription", { name: form.name || selectedProfile().label }) }}</p>
<DialogTitle>{{ t(nacosNamespacePickerTitleKey) }}</DialogTitle>
<p class="text-sm text-muted-foreground">{{ t(nacosNamespacePickerDescriptionKey, { name: form.name || selectedProfile().label }) }}</p>
</DialogHeader>
<div class="flex items-center gap-2 rounded-md border bg-background px-2">
<Search class="h-4 w-4 shrink-0 text-muted-foreground" />
<Input v-model="visibleNacosNamespaceSearchText" :placeholder="t('nacos.nacosSearchNamespaces')" class="h-8 border-0 px-0 shadow-none focus-visible:ring-0" :disabled="isLoadingVisibleNacosNamespaces || !!visibleNacosNamespaceError" />
</div>
<Tabs v-model="visibleNacosNamespaceAccessMode" class="grid gap-3">
<TabsList class="grid h-9 w-full" :class="canDetectNacosNamespaceAccess ? 'grid-cols-2' : 'grid-cols-1'">
<TabsTrigger value="automatic">{{ t("nacos.nacosNamespaceAccessAutomatic") }}</TabsTrigger>
<TabsTrigger v-if="canDetectNacosNamespaceAccess" value="manual">{{ t("nacos.nacosNamespaceAccessManual") }}</TabsTrigger>
</TabsList>
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>{{ t("nacos.nacosSelectedNamespaces", { selected: visibleNacosNamespaceSelectedCount, total: visibleNacosNamespaces.length }) }}</span>
<div class="flex items-center gap-2">
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingVisibleNacosNamespaces" @click="selectAllVisibleNacosNamespaces">{{ t("nacos.nacosSelectAll") }}</button>
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingVisibleNacosNamespaces" @click="clearVisibleNacosNamespaceSelection">{{ t("nacos.nacosClearSelection") }}</button>
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingVisibleNacosNamespaces" @click="showAllVisibleNacosNamespaces">{{ t("nacos.nacosShowAll") }}</button>
</div>
</div>
<p v-if="!isLoadingVisibleNacosNamespaces && !visibleNacosNamespaceError && !visibleNacosNamespaceCanSave" class="text-xs text-destructive">{{ t("nacos.nacosNamespaceSelectionRequired") }}</p>
<TabsContent value="automatic" class="m-0 grid gap-3">
<div class="flex items-center gap-2 rounded-md border bg-background px-2">
<Search class="h-4 w-4 shrink-0 text-muted-foreground" />
<Input v-model="visibleNacosNamespaceSearchText" :placeholder="t('nacos.nacosSearchNamespaces')" class="h-8 border-0 px-0 shadow-none focus-visible:ring-0" :disabled="isLoadingVisibleNacosNamespaces || !!visibleNacosNamespaceError" />
</div>
<div class="h-72 overflow-y-auto rounded-md border bg-background/50 p-1">
<div v-if="isLoadingVisibleNacosNamespaces" class="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("common.loading") }}
</div>
<div v-else-if="visibleNacosNamespaceError" class="p-3 text-sm text-destructive">{{ t("nacos.nacosLoadNamespacesFailed", { message: visibleNacosNamespaceError }) }}</div>
<div v-else-if="!filteredVisibleNacosNamespaces.length" class="p-3 text-sm text-muted-foreground">{{ t("grid.noSearchResults") }}</div>
<template v-else>
<button
v-for="namespace in filteredVisibleNacosNamespaces"
:key="nacosNamespaceValue(namespace) || '__public__'"
type="button"
class="flex min-h-9 w-full min-w-0 items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none"
@click="toggleVisibleNacosNamespace(nacosNamespaceValue(namespace))"
>
<CheckSquare v-if="visibleNacosNamespaceSelection.has(nacosNamespaceValue(namespace))" class="h-4 w-4 shrink-0 text-primary" />
<Square v-else class="h-4 w-4 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate">{{ nacosNamespaceLabel(namespace) }}</span>
<span v-if="namespace.namespace && namespace.namespace !== nacosNamespaceLabel(namespace)" class="shrink-0 truncate text-xs text-muted-foreground">{{ namespace.namespace }}</span>
</button>
</template>
</div>
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>{{ t("nacos.nacosSelectedNamespaces", { selected: visibleNacosNamespaceSelectedCount, total: visibleNacosNamespaces.length }) }}</span>
<div class="flex items-center gap-2">
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingVisibleNacosNamespaces" @click="selectAllVisibleNacosNamespaces">{{ t("nacos.nacosSelectAll") }}</button>
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingVisibleNacosNamespaces" @click="clearVisibleNacosNamespaceSelection">{{ t("nacos.nacosClearSelection") }}</button>
<button v-if="visibleNacosNamespaceDynamicAllSupported" class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingVisibleNacosNamespaces" @click="showAllVisibleNacosNamespaces">{{ t("nacos.nacosShowAll") }}</button>
</div>
</div>
<p v-if="!isLoadingVisibleNacosNamespaces && !visibleNacosNamespaceError && !visibleNacosNamespaceCanSave" class="text-xs text-destructive">{{ t("nacos.nacosNamespaceSelectionRequired") }}</p>
<div class="h-72 overflow-y-auto rounded-md border bg-background/50 p-1">
<div v-if="isLoadingVisibleNacosNamespaces" class="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("common.loading") }}
</div>
<div v-else-if="visibleNacosNamespaceListingPermissionDenied" class="grid gap-3 p-3 text-sm text-destructive">
<p>{{ visibleNacosNamespaceError }}</p>
<Button v-if="canDetectNacosNamespaceAccess" variant="outline" size="sm" class="justify-self-start" @click="visibleNacosNamespaceAccessMode = 'manual'">
{{ t("nacos.nacosNamespaceAccessManual") }}
</Button>
</div>
<div v-else-if="visibleNacosNamespaceError" class="p-3 text-sm text-destructive">{{ t("nacos.nacosLoadNamespacesFailed", { message: visibleNacosNamespaceError }) }}</div>
<div v-else-if="!filteredVisibleNacosNamespaces.length" class="p-3 text-sm text-muted-foreground">{{ t("grid.noSearchResults") }}</div>
<template v-else>
<button
v-for="namespace in filteredVisibleNacosNamespaces"
:key="nacosNamespaceValue(namespace) || '__public__'"
type="button"
class="flex min-h-9 w-full min-w-0 items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none"
@click="toggleVisibleNacosNamespace(nacosNamespaceValue(namespace))"
>
<CheckSquare v-if="visibleNacosNamespaceSelection.has(nacosNamespaceValue(namespace))" class="h-4 w-4 shrink-0 text-primary" />
<Square v-else class="h-4 w-4 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate">{{ nacosNamespaceLabel(namespace) }}</span>
<span v-if="namespace.namespace && namespace.namespace !== nacosNamespaceLabel(namespace)" class="shrink-0 truncate text-xs text-muted-foreground">{{ namespace.namespace }}</span>
</button>
</template>
</div>
</TabsContent>
<TabsContent v-if="canDetectNacosNamespaceAccess" value="manual" class="m-0 grid gap-3">
<p class="text-xs leading-5 text-muted-foreground">{{ t("nacos.nacosNamespaceAccessScopeHint") }}</p>
<div v-if="isNacosV3AdminPlane" class="flex gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2.5 text-xs leading-5 text-amber-800 dark:text-amber-300">
<ShieldAlert class="mt-0.5 h-4 w-4 shrink-0" />
<p>{{ t("nacos.nacosV3AdminManagedNamespacesHint") }}</p>
</div>
<div class="grid gap-1.5">
<div class="flex items-center justify-between gap-3">
<Label>{{ t(nacosManualNamespaceLabelKey) }}</Label>
<span class="text-[11px] text-muted-foreground">{{ t("nacos.nacosManagedNamespacesSeparator") }}</span>
</div>
<textarea
v-model="nacosManagedNamespacesText"
data-nacos-managed-namespaces
rows="6"
class="min-h-32 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(nacosManualNamespacePlaceholderKey)"
/>
<p class="text-[11px] leading-4 text-muted-foreground">{{ t(nacosManualNamespaceHintKey) }}</p>
</div>
<p v-if="!visibleNacosNamespaceCanSave" class="text-xs text-destructive">{{ t("nacos.nacosOrdinaryNamespacesRequired") }}</p>
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant="outline" @click="showVisibleNacosNamespacesDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="isLoadingVisibleNacosNamespaces || !!visibleNacosNamespaceError || !visibleNacosNamespaceCanSave" @click="saveVisibleNacosNamespaceSelection">{{ t("nacos.save") }}</Button>
<Button :disabled="isResolvingManualNacosNamespaces || !visibleNacosNamespaceCanSave || (visibleNacosNamespaceAccessMode === 'automatic' && (isLoadingVisibleNacosNamespaces || !!visibleNacosNamespaceError))" @click="saveVisibleNacosNamespaceSelection">{{ t("nacos.save") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -1,6 +1,6 @@
<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 { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { KeyRound, Loader2, Pencil, Plus, RefreshCw, Search, ShieldCheck, ShieldPlus, Trash2, UserRound } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -11,9 +11,10 @@ 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 { subscribeNacosNamespacesChanged, type NacosNamespacesChangedDetail } from "@/lib/nacos/nacosNamespaceCache";
import { normalizeNacosNamespacesForDisplay } from "@/lib/nacos/nacosNamespaceVisibility";
import { useConnectionStore } from "@/stores/connectionStore";
import type { NacosConnectionInfo, NacosNamespaceInfo, NacosNamespacePrivilege, NacosOperationCapability, NacosRoleBinding, NacosUserInfo } from "@/types/nacos";
import type { NacosAdminConfig, NacosConnectionInfo, NacosNamespaceInfo, NacosNamespacePrivilege, NacosOperationCapability, NacosRoleBinding, NacosUserInfo } from "@/types/nacos";
const props = defineProps<{
connectionId: string;
@@ -30,6 +31,7 @@ type UserDialogMode = "create" | "edit" | "reset";
const users = ref<NacosUserInfo[]>([]);
const usersLoading = ref(false);
const usersError = ref("");
const userOperationNotice = ref<{ kind: "success" | "error"; message: string } | null>(null);
const userSearch = ref("");
const userPageNo = ref(1);
const userPageSize = ref(20);
@@ -53,6 +55,7 @@ const userFormNamespaces = ref<NacosNamespaceInfo[]>([]);
const userFormNamespacesLoading = ref(false);
const userFormNamespacesError = ref("");
const pendingUserDelete = ref<NacosUserInfo | null>(null);
const userDeleteConfirmation = ref("");
const deletingUser = ref(false);
const roleDialogOpen = ref(false);
const roleDialogError = ref("");
@@ -67,12 +70,15 @@ const rnacosCaptchaImage = ref("");
const rnacosCaptcha = ref("");
const rnacosAuthError = ref("");
let rnacosRetry: (() => Promise<void>) | null = null;
let latestUserFormNamespacesRequestId = 0;
let stopNacosNamespacesChangedListener: (() => 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))));
const userDeleteConfirmed = computed(() => userDeleteConfirmation.value === pendingUserDelete.value?.username);
function createUserForm() {
return {
@@ -264,20 +270,35 @@ async function loadRoles(page = rolePageNo.value) {
async function loadUserFormNamespaces(force = false) {
if (!isRNacos.value || userFormNamespacesLoading.value || (userFormNamespaces.value.length && !force)) return;
const connectionId = props.connectionId;
const requestId = ++latestUserFormNamespacesRequestId;
userFormNamespacesLoading.value = true;
userFormNamespacesError.value = "";
try {
userFormNamespaces.value = normalizeNacosNamespacesForDisplay(await api.nacosListNamespaces(props.connectionId));
const namespaces = normalizeNacosNamespacesForDisplay(await api.nacosListNamespaces(connectionId));
if (requestId !== latestUserFormNamespacesRequestId || connectionId !== props.connectionId) return;
userFormNamespaces.value = namespaces;
} catch (error) {
if (requestId !== latestUserFormNamespacesRequestId || connectionId !== props.connectionId) return;
userFormNamespacesError.value = error instanceof Error ? error.message : String(error);
} finally {
userFormNamespacesLoading.value = false;
if (requestId === latestUserFormNamespacesRequestId) userFormNamespacesLoading.value = false;
}
}
function handleNacosNamespacesChanged(detail: NacosNamespacesChangedDetail) {
if (detail.connectionId !== props.connectionId) return;
latestUserFormNamespacesRequestId += 1;
userFormNamespaces.value = [];
userFormNamespacesError.value = "";
userFormNamespacesLoading.value = false;
if (userDialogOpen.value && isRNacos.value) void loadUserFormNamespaces(true);
}
function openCreateUser() {
userDialogMode.value = "create";
userDialogError.value = "";
userOperationNotice.value = null;
userForm.value = createUserForm();
userDialogOpen.value = true;
void loadUserFormNamespaces();
@@ -286,6 +307,7 @@ function openCreateUser() {
function openEditUser(user: NacosUserInfo) {
userDialogMode.value = isRNacos.value ? "edit" : "reset";
userDialogError.value = "";
userOperationNotice.value = null;
const privilege = user.namespacePrivilege;
userForm.value = {
username: user.username,
@@ -305,8 +327,10 @@ function openEditUser(user: NacosUserInfo) {
watch(
() => props.connectionId,
() => {
latestUserFormNamespacesRequestId += 1;
userFormNamespaces.value = [];
userFormNamespacesError.value = "";
userFormNamespacesLoading.value = false;
},
);
@@ -320,6 +344,7 @@ function toggleRNacosRole(role: string, checked: boolean) {
async function saveUser() {
if (userSaving.value) return;
const username = userForm.value.username.trim();
const resettingPassword = userDialogMode.value === "reset";
if (!username || (userDialogMode.value !== "edit" && !userForm.value.password)) {
userDialogError.value = t("nacos.userCredentialsRequired");
return;
@@ -350,14 +375,61 @@ async function saveUser() {
}
userDialogOpen.value = false;
await loadUsers(1);
if (resettingPassword) {
const handledConnectionCredential = await syncCurrentConnectionPassword(username, userForm.value.password);
if (!handledConnectionCredential) {
userOperationNotice.value = { kind: "success", message: t("nacos.passwordResetSucceeded", { username }) };
}
}
});
} catch (error) {
userDialogError.value = error instanceof Error ? error.message : String(error);
const message = error instanceof Error ? error.message : String(error);
userDialogError.value = message;
if (resettingPassword) userOperationNotice.value = { kind: "error", message: t("nacos.passwordResetFailed", { message }) };
} finally {
userSaving.value = false;
}
}
async function syncCurrentConnectionPassword(username: string, password: string): Promise<boolean> {
const config = connectionStore.getConfig(props.connectionId);
if (!config || config.db_type !== "nacos") return false;
const hasExternalConfig = !!config.external_config && typeof config.external_config === "object";
const externalConfig = (config.external_config || {}) as NacosAdminConfig;
const primaryAuth = externalConfig.auth;
const primaryMatches = (primaryAuth?.kind === "usernamePassword" && primaryAuth.username === username) || (!hasExternalConfig && config.username === username);
const consoleAuth = externalConfig.rnacosConsoleAuth;
const consoleMatches = consoleAuth?.kind === "usernamePassword" && consoleAuth.username === username;
if (!primaryMatches && !consoleMatches) return false;
if (config.save_password === false) {
try {
await api.replaceNacosSessionCredential(props.connectionId, username, password);
userOperationNotice.value = { kind: "success", message: `${t("nacos.passwordResetSucceeded", { username })} ${t("nacos.currentSessionPasswordUpdated")}` };
} catch {
userOperationNotice.value = { kind: "success", message: `${t("nacos.passwordResetSucceeded", { username })} ${t("nacos.currentPasswordNotSaved")}` };
}
return true;
}
const nextExternal: NacosAdminConfig | undefined = hasExternalConfig
? {
...externalConfig,
auth: primaryMatches ? { kind: "usernamePassword", username, password } : primaryAuth,
rnacosConsoleAuth: consoleMatches ? { kind: "usernamePassword", username, password } : consoleAuth,
}
: undefined;
try {
await connectionStore.updateConnection({
...config,
password: primaryMatches ? password : config.password,
external_config: nextExternal,
});
userOperationNotice.value = { kind: "success", message: `${t("nacos.passwordResetSucceeded", { username })} ${t("nacos.currentPasswordSaved")}` };
} catch {
userOperationNotice.value = { kind: "success", message: `${t("nacos.passwordResetSucceeded", { username })} ${t("nacos.currentPasswordNotSaved")}` };
}
return true;
}
async function deleteUser() {
const user = pendingUserDelete.value;
if (!user || deletingUser.value) return;
@@ -367,6 +439,7 @@ async function deleteUser() {
await withRNacosAuthentication(async () => {
await api.nacosDeleteUser(props.connectionId, user.username);
pendingUserDelete.value = null;
userDeleteConfirmation.value = "";
await loadUsers(Math.min(userPageNo.value, userTotalPages.value));
});
} catch (error) {
@@ -376,6 +449,16 @@ async function deleteUser() {
}
}
function openDeleteUser(user: NacosUserInfo) {
pendingUserDelete.value = user;
userDeleteConfirmation.value = "";
}
function closeDeleteUser() {
pendingUserDelete.value = null;
userDeleteConfirmation.value = "";
}
function openAssignRole() {
roleDialogError.value = "";
roleForm.value = { username: roleUsername.value.trim(), role: "" };
@@ -439,9 +522,20 @@ watch(
);
onMounted(() => {
stopNacosNamespacesChangedListener = subscribeNacosNamespacesChanged(handleNacosNamespacesChanged);
if (props.tab === "users") void loadUsers(1);
else void loadRoles(1);
});
onBeforeUnmount(() => {
latestUserFormNamespacesRequestId += 1;
stopNacosNamespacesChangedListener?.();
stopNacosNamespacesChangedListener = null;
});
defineExpose({
refresh: () => (props.tab === "users" ? loadUsers() : loadRoles()),
});
</script>
<template>
@@ -464,6 +558,9 @@ onMounted(() => {
</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 v-if="userOperationNotice" class="border-b px-3 py-2 text-xs" :class="userOperationNotice.kind === 'success' ? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'" role="status" aria-live="polite">
{{ userOperationNotice.message }}
</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">
@@ -493,7 +590,7 @@ onMounted(() => {
<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>
><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="openDeleteUser(user)"><Trash2 class="h-3.5 w-3.5" /></Button>
</div>
</td>
</tr>
@@ -519,7 +616,7 @@ onMounted(() => {
<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>
<Button size="sm" class="h-8 gap-1.5" :disabled="!isWritable(assignRoleCapability())" :title="!isWritable(assignRoleCapability()) ? capabilityReason(assignRoleCapability()) : undefined" @click="openAssignRole"><ShieldPlus 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">
@@ -645,14 +742,22 @@ onMounted(() => {
></Dialog
>
<Dialog :open="!!pendingUserDelete" @update:open="!$event && (pendingUserDelete = null)"
<Dialog :open="!!pendingUserDelete" @update:open="!$event && closeDeleteUser()"
><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
>
<div class="grid gap-1.5">
<Label for="legacy-delete-user-confirm">{{ t("nacos.accessDeleteUserConfirmationLabel") }}</Label>
<p id="legacy-delete-user-confirm-hint" class="text-xs text-muted-foreground">
{{ t("nacos.accessDeleteUserConfirmationHint") }} <code class="select-all rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">{{ pendingUserDelete?.username }}</code>
</p>
<Input id="legacy-delete-user-confirm" v-model="userDeleteConfirmation" autocomplete="off" :placeholder="t('nacos.accessDeleteUserConfirmationPlaceholder')" aria-describedby="legacy-delete-user-confirm-hint" />
</div>
<DialogFooter
><Button variant="outline" :disabled="deletingUser" @click="closeDeleteUser">{{ t("nacos.cancel") }}</Button
><Button variant="destructive" :disabled="deletingUser || !userDeleteConfirmed" @click="deleteUser"><Loader2 v-if="deletingUser" class="mr-2 h-4 w-4 animate-spin" />{{ t("nacos.delete") }}</Button></DialogFooter
></DialogContent
></Dialog
>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { computed, nextTick, ref, watch } from "vue";
import { KeyRound, Loader2, RefreshCw, ShieldCheck, UsersRound } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Badge } from "@/components/ui/badge";
@@ -16,6 +16,7 @@ const props = defineProps<{
}>();
type AccessTab = "users" | "roles";
type AccessControlWorkspace = { refresh: () => Promise<void> };
const { t } = useI18n();
const connectionStore = useConnectionStore();
@@ -23,6 +24,8 @@ const connectionInfo = ref<NacosConnectionInfo | null>(null);
const connectionError = ref("");
const loading = ref(false);
const activeTab = ref<AccessTab>("users");
const legacyWorkspace = ref<AccessControlWorkspace | null>(null);
const enhancedWorkspaceRef = ref<AccessControlWorkspace | null>(null);
const accessControl = computed(() => connectionInfo.value?.capabilities.accessControl);
const supportsUsers = computed(() => accessControl.value?.listUsers.supported === true);
@@ -30,13 +33,17 @@ const supportsRoles = computed(() => accessControl.value?.mode === "roleBindings
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() {
async function loadConnectionInfo(refreshWorkspace = false) {
loading.value = true;
connectionError.value = "";
try {
await connectionStore.ensureConnected(props.connectionId);
connectionInfo.value = await api.nacosTestConnection(props.connectionId);
connectionInfo.value = await api.nacosTestConnection(props.connectionId, refreshWorkspace);
if (!supportsUsers.value && supportsRoles.value) activeTab.value = "roles";
if (refreshWorkspace) {
await nextTick();
await (enhancedWorkspace.value ? enhancedWorkspaceRef.value : legacyWorkspace.value)?.refresh();
}
} catch (error) {
connectionInfo.value = null;
connectionError.value = error instanceof Error ? error.message : String(error);
@@ -74,7 +81,7 @@ watch(
<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">
<Button size="sm" variant="outline" class="h-8 gap-1.5" :disabled="loading" @click="loadConnectionInfo(true)">
<RefreshCw class="h-3.5 w-3.5" :class="loading ? 'animate-spin' : ''" />
{{ t("nacos.refresh") }}
</Button>
@@ -88,8 +95,8 @@ watch(
</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" />
<NacosRoleAccessControl v-if="enhancedWorkspace && accessControl" ref="enhancedWorkspaceRef" :connection-id="connectionId" :capabilities="accessControl" :read-only="readOnly" :tab="activeTab" @select-user="activeTab = 'users'" @select-role="activeTab = 'roles'" />
<NacosAccessControl v-else-if="supportsUsers || supportsRoles" ref="legacyWorkspace" :connection-id="connectionId" :connection-info="connectionInfo" :read-only="readOnly" :tab="activeTab" />
</template>
</section>
</template>
@@ -313,6 +313,7 @@ const updateServiceCapability = computed(() => operationCapability(serviceCapabi
const deleteServiceCapability = computed(() => operationCapability(serviceCapabilities.value?.deleteService, createServiceCapability.value.supported));
const listInstancesCapability = computed(() => operationCapability(serviceCapabilities.value?.listInstances, listServicesCapability.value.supported));
const updateInstanceCapability = computed(() => operationCapability(serviceCapabilities.value?.updateInstance, legacyInstanceUpdateSupported.value));
const updateInstanceHealthCapability = computed(() => operationCapability(serviceCapabilities.value?.updateInstanceHealth, updateInstanceCapability.value.supported));
const registerInstanceCapability = computed(() => operationCapability(serviceCapabilities.value?.registerInstance, updateInstanceCapability.value.supported));
const deregisterInstanceCapability = computed(() => operationCapability(serviceCapabilities.value?.deregisterInstance, updateInstanceCapability.value.supported));
const supportsServiceManagement = computed(() => listServicesCapability.value.supported);
@@ -2876,9 +2877,7 @@ onBeforeUnmount(() => {
</div>
<div class="flex items-center gap-1 rounded-md border bg-background p-1">
<span class="px-1 text-xs text-muted-foreground">{{ t("nacos.instances") }}</span>
<Button size="sm" class="h-7" :disabled="readOnly || !registerInstanceCapability.supported" :title="readOnly || !registerInstanceCapability.supported ? capabilityReason(registerInstanceCapability) : undefined" @click="registerInstanceOpen = true">{{
t("nacos.registerInstance")
}}</Button>
<Button v-if="registerInstanceCapability.supported" size="sm" class="h-7" :disabled="readOnly" :title="readOnly ? capabilityReason(registerInstanceCapability) : undefined" @click="registerInstanceOpen = true">{{ t("nacos.registerInstance") }}</Button>
</div>
</div>
</div>
@@ -2986,15 +2985,16 @@ onBeforeUnmount(() => {
<Loader2 v-if="isInstanceUpdating(instance)" class="h-3 w-3 animate-spin" />
{{ instance.enabled === false ? t("nacos.enable") : t("nacos.disable") }}
</Button>
<Button size="sm" variant="outline" class="h-7" :disabled="readOnly || !supportsInstanceUpdate || isInstanceUpdating(instance)" @click="requestUpdateInstance(instance, { healthy: !instance.healthy })">
<Button v-if="updateInstanceHealthCapability.supported" size="sm" variant="outline" class="h-7" :disabled="readOnly || isInstanceUpdating(instance)" @click="requestUpdateInstance(instance, { healthy: !instance.healthy })">
{{ instance.healthy === false ? t("nacos.markHealthy") : t("nacos.markUnhealthy") }}
</Button>
<Button
v-if="deregisterInstanceCapability.supported"
size="sm"
variant="outline"
class="h-7 text-destructive"
:disabled="readOnly || !deregisterInstanceCapability.supported || isInstanceUpdating(instance)"
:title="readOnly || !deregisterInstanceCapability.supported ? capabilityReason(deregisterInstanceCapability) : undefined"
:disabled="readOnly || isInstanceUpdating(instance)"
:title="readOnly ? capabilityReason(deregisterInstanceCapability) : undefined"
@click="pendingInstanceDeregister = instance"
>{{ t("nacos.deregister") }}</Button
>
@@ -368,11 +368,11 @@ onUnmounted(() => {
<MetricCard :label="t('nacos.dashboardNamespaces')" :value="optionalCount(snapshot?.namespaceCount)" :icon="Layers3" />
<MetricCard :label="t('nacos.dashboardConfigs')" :value="optionalCount(snapshot?.configCount)" :sub="namespaceLabel" :icon="Braces" />
<MetricCard :label="t('nacos.dashboardServices')" :value="optionalCount(snapshot?.serviceCount)" :sub="namespaceLabel" :icon="Boxes" />
<MetricCard :label="t('nacos.dashboardInstances')" :value="optionalCount(metrics?.instanceCount)" :icon="Network" />
<MetricCard :label="t('nacos.dashboardClients')" :value="optionalCount(metrics?.clientCount)" :sub="t('nacos.dashboardConnections', { count: optionalCount(metrics?.connectionBasedClientCount) })" :icon="Users" />
<MetricCard v-if="metrics" :label="t('nacos.dashboardInstances')" :value="optionalCount(metrics.instanceCount)" :icon="Network" />
<MetricCard v-if="metrics" :label="t('nacos.dashboardClients')" :value="optionalCount(metrics.clientCount)" :sub="t('nacos.dashboardConnections', { count: optionalCount(metrics.connectionBasedClientCount) })" :icon="Users" />
<MetricCard :label="t('nacos.dashboardNodes')" :value="snapshot?.nodes.length ? `${healthyNodes} / ${snapshot.nodes.length}` : '—'" :icon="Server" />
<MetricCard :label="t('nacos.dashboardCpu')" :value="formatDashboardPercent(metrics?.cpu)" :icon="Cpu" />
<MetricCard :label="t('nacos.dashboardMemory')" :value="formatDashboardPercent(metrics?.mem)" :sub="t('nacos.dashboardLoad', { value: optionalMetric(metrics?.load) })" :icon="HardDrive" />
<MetricCard v-if="metrics" :label="t('nacos.dashboardCpu')" :value="formatDashboardPercent(metrics.cpu)" :icon="Cpu" />
<MetricCard v-if="metrics" :label="t('nacos.dashboardMemory')" :value="formatDashboardPercent(metrics.mem)" :sub="t('nacos.dashboardLoad', { value: optionalMetric(metrics.load) })" :icon="HardDrive" />
</div>
<div v-if="prometheus" class="grid shrink-0 grid-cols-2 gap-3 sm:grid-cols-4">
@@ -1,17 +1,19 @@
<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 { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { AlertTriangle, Check, ChevronLeft, ChevronRight, CircleAlert, KeyRound, Link2, Loader2, LockKeyhole, Pencil, Plus, RefreshCw, RotateCcw, Search, Shield, ShieldCheck, ShieldPlus, Trash2, UserPlus, 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 { useToast } from "@/composables/useToast";
import * as api from "@/lib/backend/api";
import { executeWithProductionContextGuard } from "@/lib/database/productionExecutionGuard";
import { mergeNacosNamespacePermissionAssignments, type NacosNamespacePermissionAction } from "@/lib/nacos/nacosAdmin";
import { subscribeNacosNamespacesChanged, type NacosNamespacesChangedDetail } from "@/lib/nacos/nacosNamespaceCache";
import { useConnectionStore } from "@/stores/connectionStore";
import type { NacosAccessControlCapabilities, NacosAccessControlSnapshot, NacosAccessOperationRequest, NacosAccessOperationResult, NacosPermissionDraft, NacosPermissionInfo, NacosUserInfo } from "@/types/nacos";
import type { NacosAccessControlCapabilities, NacosAccessControlSnapshot, NacosAccessOperationRequest, NacosAccessOperationResult, NacosAdminConfig, NacosPermissionDraft, NacosPermissionInfo, NacosUserInfo } from "@/types/nacos";
const props = defineProps<{
connectionId: string;
@@ -28,6 +30,7 @@ type PermissionAction = NacosNamespacePermissionAction;
type PermissionAssignment = { namespaceId: string; action: PermissionAction };
const { t } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const snapshot = ref<NacosAccessControlSnapshot | null>(null);
const loading = ref(false);
@@ -59,6 +62,8 @@ const operation = ref<NacosAccessOperationResult | null>(null);
const pendingAdvancedPermission = ref<NacosPermissionInfo | null>(null);
const advancedPermissionConfirmation = ref("");
const retryPasswords = reactive<Record<string, string>>({});
let latestSnapshotRequestId = 0;
let stopNacosNamespacesChangedListener: (() => void) | null = null;
const users = computed(() => snapshot.value?.users ?? []);
const roles = computed(() => snapshot.value?.roles ?? []);
@@ -122,6 +127,8 @@ const canRevokePermission = computed(() => !props.readOnly && props.capabilities
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);
const userDeleteConfirmed = computed(() => userForm.confirmation === userForm.username);
const roleDeleteConfirmed = computed(() => roleForm.confirmation === roleForm.role);
function setHasAdded(before: Iterable<string>, after: Iterable<string>) {
const original = new Set(before);
@@ -179,10 +186,14 @@ const roleOperationWritable = computed(() => {
});
async function loadSnapshot(preserveSelection = true) {
const requestId = ++latestSnapshotRequestId;
const connectionId = props.connectionId;
loading.value = true;
error.value = "";
try {
snapshot.value = await api.nacosAccessSnapshot(props.connectionId);
const nextSnapshot = await api.nacosAccessSnapshot(connectionId);
if (requestId !== latestSnapshotRequestId || connectionId !== props.connectionId) return;
snapshot.value = nextSnapshot;
if (!preserveSelection || !users.value.some((user) => user.username === selectedUserName.value)) {
selectedUserName.value = users.value[0]?.username ?? "";
}
@@ -190,12 +201,17 @@ async function loadSnapshot(preserveSelection = true) {
selectedRoleName.value = roles.value[0]?.role ?? "";
}
} catch (cause) {
if (requestId !== latestSnapshotRequestId || connectionId !== props.connectionId) return;
error.value = cause instanceof Error ? cause.message : String(cause);
} finally {
loading.value = false;
if (requestId === latestSnapshotRequestId) loading.value = false;
}
}
function handleNacosNamespacesChanged(detail: NacosNamespacesChangedDetail) {
if (detail.connectionId === props.connectionId) void loadSnapshot();
}
function isManagedPermission(permission: NacosPermissionInfo) {
return permission.parsedScope?.kind === "namespace" && ["r", "w", "rw"].includes(permission.actionRaw);
}
@@ -317,6 +333,45 @@ function openDeleteUser() {
userDialog.value = "delete";
}
async function syncCurrentConnectionPassword(username: string, password: string) {
if (snapshot.value?.currentUsername !== username) return;
const config = connectionStore.getConfig(props.connectionId);
if (!config || config.db_type !== "nacos") return;
const hasExternalConfig = !!config.external_config && typeof config.external_config === "object";
const externalConfig = (config.external_config || {}) as NacosAdminConfig;
const primaryAuth = externalConfig.auth;
const primaryMatches = (primaryAuth?.kind === "usernamePassword" && primaryAuth.username === username) || (!hasExternalConfig && config.username === username);
const consoleAuth = externalConfig.rnacosConsoleAuth;
const consoleMatches = consoleAuth?.kind === "usernamePassword" && consoleAuth.username === username;
if (!primaryMatches && !consoleMatches) return;
if (config.save_password === false) {
try {
await api.replaceNacosSessionCredential(props.connectionId, username, password);
toast(t("nacos.currentSessionPasswordUpdated"), 4000);
} catch {
toast(t("nacos.currentPasswordNotSaved"), 5000);
}
return;
}
const nextExternal: NacosAdminConfig | undefined = hasExternalConfig
? {
...externalConfig,
auth: primaryMatches ? { kind: "usernamePassword", username, password } : primaryAuth,
rnacosConsoleAuth: consoleMatches ? { kind: "usernamePassword", username, password } : consoleAuth,
}
: undefined;
try {
await connectionStore.updateConnection({
...config,
password: primaryMatches ? password : config.password,
external_config: nextExternal,
});
toast(t("nacos.currentPasswordSaved"), 4000);
} catch {
toast(t("nacos.currentPasswordNotSaved"), 5000);
}
}
function resetRoleForm(role = "") {
Object.assign(roleForm, { role, members: [], newUsers: [], permissions: [], confirmation: "" });
availableNamespaceSearch.value = "";
@@ -342,7 +397,7 @@ function openEditRole() {
function openDeleteRole() {
if (!selectedRole.value) return;
roleForm.confirmation = "";
resetRoleForm(selectedRole.value.role);
formError.value = "";
roleDialog.value = "delete";
}
@@ -360,7 +415,10 @@ function openAssociatedRole(role: string) {
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));
return items.filter((item) => {
const id = item.id ?? item.namespaceId ?? "";
return item.name.toLocaleLowerCase().includes(normalized) || id.toLocaleLowerCase().includes(normalized);
});
}
function moveNamespacesToGranted() {
@@ -393,12 +451,17 @@ async function submitUser() {
if (userDialog.value === "password") {
if (!userForm.password) return void (formError.value = t("nacos.accessPasswordRequired"));
if (!(await confirmAccessMutation(`${t("nacos.accessUserDialog.password")}: ${userForm.username}`))) return;
const username = userForm.username;
saving.value = true;
try {
await api.nacosUpdateUser(props.connectionId, { username: userForm.username, password: userForm.password });
await api.nacosUpdateUser(props.connectionId, { username, password: userForm.password });
await syncCurrentConnectionPassword(username, userForm.password);
userDialog.value = null;
toast(t("nacos.passwordResetSucceeded", { username }), 2500);
} catch (cause) {
formError.value = cause instanceof Error ? cause.message : String(cause);
const message = cause instanceof Error ? cause.message : String(cause);
formError.value = message;
toast(t("nacos.passwordResetFailed", { message }), 5000);
} finally {
saving.value = false;
}
@@ -422,7 +485,8 @@ async function submitRole() {
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)) {
const roleRequiresMember = roleDialog.value === "create" || selectedRole.value?.administrator;
if (!roleForm.role.trim() || (roleRequiresMember && !roleForm.members.length && !roleForm.newUsers.length)) {
formError.value = t("nacos.accessRoleMemberRequired");
return;
}
@@ -518,7 +582,17 @@ watch(
},
);
watch(normalizedSearch, () => (page.value = 1));
onMounted(() => void loadSnapshot(false));
onMounted(() => {
stopNacosNamespacesChangedListener = subscribeNacosNamespacesChanged(handleNacosNamespacesChanged);
void loadSnapshot(false);
});
onBeforeUnmount(() => {
latestSnapshotRequestId += 1;
stopNacosNamespacesChangedListener?.();
stopNacosNamespacesChangedListener = null;
});
defineExpose({ refresh: () => loadSnapshot() });
</script>
<template>
@@ -529,8 +603,16 @@ onMounted(() => void loadSnapshot(false));
<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
size="sm"
class="h-8 w-8 shrink-0 p-0"
:title="tab === 'users' ? t('nacos.createUser') : t('nacos.accessCreateRole')"
:aria-label="tab === 'users' ? t('nacos.createUser') : t('nacos.accessCreateRole')"
:disabled="tab === 'users' ? !canCreateUser : !canCreateRole"
@click="tab === 'users' ? openCreateUser() : openCreateRole()"
>
<UserPlus v-if="tab === 'users'" class="h-3.5 w-3.5" />
<ShieldPlus v-else class="h-3.5 w-3.5" />
</Button>
</div>
@@ -554,8 +636,7 @@ onMounted(() => void loadSnapshot(false));
<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="shrink-0 border-t p-2">
<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">
@@ -787,20 +868,29 @@ onMounted(() => void loadSnapshot(false));
</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">
<div v-if="userDialog === 'delete'" class="grid gap-1.5">
<Label for="access-delete-user-confirm">{{ t("nacos.accessDeleteUserConfirmationLabel") }}</Label>
<p id="access-delete-user-confirm-hint" class="text-xs text-muted-foreground">
{{ t("nacos.accessDeleteUserConfirmationHint") }} <code class="select-all rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">{{ userForm.username }}</code>
</p>
<Input id="access-delete-user-confirm" v-model="userForm.confirmation" autocomplete="off" :placeholder="t('nacos.accessDeleteUserConfirmationPlaceholder')" aria-describedby="access-delete-user-confirm-hint" />
</div>
<div v-if="userDialog !== 'delete' && (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
><Button :variant="userDialog === 'delete' ? 'destructive' : 'default'" :disabled="saving || !userOperationWritable || (userDialog === 'delete' && !userDeleteConfirmed)" @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">
<DialogContent class="max-h-[90vh] overflow-auto sm:max-w-3xl">
<DialogHeader
><DialogTitle>{{ t(`nacos.accessRoleDialog.${roleDialog}`) }}</DialogTitle
><DialogDescription>{{ t(`nacos.accessRoleDialog.${roleDialog}Hint`) }}</DialogDescription></DialogHeader
@@ -815,7 +905,9 @@ onMounted(() => void loadSnapshot(false));
<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>
<p class="mt-0.5 text-xs text-muted-foreground">
{{ t(roleDialog === "edit" && !selectedRole?.administrator ? "nacos.accessMembersOptionalHint" : "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>
@@ -843,17 +935,21 @@ onMounted(() => void loadSnapshot(false));
<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="grid h-[min(28rem,45vh)] 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 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')" :aria-label="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">
<label v-for="namespace in availableNamespaces" :key="namespace.id" class="flex min-h-11 items-center gap-2 rounded px-2 py-1 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>
<span class="min-w-0 flex-1">
<span class="block truncate font-medium" :title="namespace.name">{{ namespace.name }}</span>
<code v-if="namespace.name !== namespace.id" class="block break-all text-[10px] leading-4 text-muted-foreground" :title="namespace.id">{{ namespace.id }}</code>
</span>
</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>
@@ -865,12 +961,16 @@ onMounted(() => void loadSnapshot(false));
<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 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')" :aria-label="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">
<div v-for="namespace in grantedNamespaces" :key="namespace.namespaceId" class="flex min-h-11 items-center gap-2 rounded px-2 py-1 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>
<span class="min-w-0 flex-1">
<span class="block truncate font-medium" :title="namespace.name">{{ namespace.name }}</span>
</span>
<div class="flex shrink-0 items-center gap-2">
<label
v-for="action in ['r', 'w', 'rw'] as PermissionAction[]"
@@ -903,13 +1003,18 @@ onMounted(() => void loadSnapshot(false));
<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
<Label for="access-delete-role-confirm">{{ t("nacos.accessDeleteRoleConfirmationLabel") }}</Label>
<p id="access-delete-role-confirm-hint" class="text-xs text-muted-foreground">
{{ t("nacos.accessDeleteRoleConfirmationHint") }} <code class="select-all rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">{{ roleForm.role }}</code>
</p>
<Input id="access-delete-role-confirm" v-model="roleForm.confirmation" autocomplete="off" :placeholder="t('nacos.accessDeleteRoleConfirmationPlaceholder')" aria-describedby="access-delete-role-confirm-hint" /></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
><Button :variant="roleDialog === 'delete' ? 'destructive' : 'default'" :disabled="saving || !roleOperationWritable || (roleDialog === 'delete' && !roleDeleteConfirmed)" @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>
@@ -9,15 +9,18 @@ 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", () => ({
refreshLegacyWorkspace: vi.fn(async () => undefined),
refreshEnhancedWorkspace: vi.fn(async () => undefined),
nacosTestConnection: vi.fn(async () => {
if (!mocks.connectionInfo) throw new Error("missing Nacos connection fixture");
return mocks.connectionInfo;
}),
}));
vi.mock("@/lib/backend/api", () => ({
nacosTestConnection: (...args: unknown[]) => mocks.nacosTestConnection(...args),
}));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({ ensureConnected: mocks.ensureConnected }),
}));
@@ -31,7 +34,10 @@ vi.mock("@/components/nacos/NacosAccessControl.vue", async () => {
return {
default: {
props: ["tab"],
setup: (props: { tab: string }) => () => h("div", { "data-testid": "nacos-access-control-workspace", "data-tab": props.tab }),
setup: (props: { tab: string }, { expose }: { expose: (value: { refresh: () => Promise<void> }) => void }) => {
expose({ refresh: mocks.refreshLegacyWorkspace });
return () => h("div", { "data-testid": "nacos-access-control-workspace", "data-tab": props.tab });
},
},
};
});
@@ -41,11 +47,14 @@ vi.mock("@/components/nacos/NacosRoleAccessControl.vue", async () => {
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),
}),
setup: (props: { capabilities: { createUser: { supported: boolean } } }, { expose }: { expose: (value: { refresh: () => Promise<void> }) => void }) => {
expose({ refresh: mocks.refreshEnhancedWorkspace });
return () =>
h("div", {
"data-testid": "nacos-role-access-control-workspace",
"data-can-create-user": String(props.capabilities.createUser.supported),
});
},
},
};
});
@@ -98,6 +107,39 @@ describe("NacosAccessControlConsole", () => {
expect(legacySource).not.toContain('<textarea v-model="userForm.blacklist"');
});
it("invalidates the r-nacos namespace picker when namespaces change", () => {
expect(legacySource).toContain('import { subscribeNacosNamespacesChanged, type NacosNamespacesChangedDetail } from "@/lib/nacos/nacosNamespaceCache"');
expect(legacySource).toContain("stopNacosNamespacesChangedListener = subscribeNacosNamespacesChanged(handleNacosNamespacesChanged)");
expect(legacySource).toContain("if (detail.connectionId !== props.connectionId) return");
expect(legacySource).toContain("if (userDialogOpen.value && isRNacos.value) void loadUserFormNamespaces(true)");
expect(legacySource).toContain("stopNacosNamespacesChangedListener?.()");
});
it("notifies users when an official Nacos password reset succeeds or fails", () => {
expect(legacySource).toContain('const userOperationNotice = ref<{ kind: "success" | "error"; message: string } | null>(null)');
expect(legacySource).toContain("await syncCurrentConnectionPassword(username, userForm.value.password)");
expect(legacySource).toContain('userOperationNotice.value = { kind: "error", message: t("nacos.passwordResetFailed", { message }) }');
expect(legacySource).toContain('v-if="userOperationNotice"');
});
it("requires exact username confirmation before deleting a user in the legacy workspace", () => {
expect(legacySource).toContain("const userDeleteConfirmed = computed(() => userDeleteConfirmation.value === pendingUserDelete.value?.username)");
expect(legacySource).toContain('@click="openDeleteUser(user)"');
expect(legacySource).toContain('t("nacos.accessDeleteUserConfirmationLabel")');
expect(legacySource).toContain(':disabled="deletingUser || !userDeleteConfirmed"');
});
it("does not hide user deletion solely because the user has ROLE_ADMIN", () => {
expect(legacySource).not.toContain('if (user.roles.includes("ROLE_ADMIN")) return');
expect(legacySource).not.toContain("v-if=\"!user.roles.includes('ROLE_ADMIN')\"");
});
it("uses the same role creation icon in the legacy and enhanced workspaces", () => {
expect(legacySource).toContain('import { KeyRound, Loader2, Pencil, Plus, RefreshCw, Search, ShieldCheck, ShieldPlus, Trash2, UserRound } from "@lucide/vue"');
expect(legacySource).toContain('@click="openAssignRole"><ShieldPlus class="h-3.5 w-3.5" />{{ t("nacos.assignRole") }}');
expect(readFileSync(resolve(process.cwd(), "apps/desktop/src/components/nacos/NacosRoleAccessControl.vue"), "utf8")).toContain('<ShieldPlus v-else class="h-3.5 w-3.5" />');
});
it("switches tabs when navigating between associated users and roles", () => {
expect(source).toContain("@select-user=\"activeTab = 'users'\"");
expect(source).toContain("@select-role=\"activeTab = 'roles'\"");
@@ -202,6 +244,53 @@ describe("NacosAccessControlConsole", () => {
expect(workspace?.getAttribute("data-can-create-user")).toBe("false");
});
it("refreshes the enhanced workspace snapshot after refreshing connection capabilities", async () => {
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: supported,
updateUser: supported,
deleteUser: supported,
listRoleBindings: supported,
assignRole: supported,
removeRole: supported,
listPermissions: supported,
grantPermission: supported,
revokePermission: supported,
enhancedWorkspace: true,
supportsNamespacePrivileges: false,
},
},
};
const host = document.createElement("div");
document.body.append(host);
const app = createApp(NacosAccessControlConsole, { connectionId: "nacos-refresh" });
mountedApps.push({ app, host });
app.mount(host);
await settle();
mocks.refreshEnhancedWorkspace.mockClear();
const refreshButton = [...host.querySelectorAll("button")].find((button) => button.textContent?.includes("nacos.refresh"));
expect(refreshButton).toBeDefined();
refreshButton?.click();
await settle();
expect(mocks.nacosTestConnection).toHaveBeenLastCalledWith("nacos-refresh", true);
expect(mocks.refreshEnhancedWorkspace).toHaveBeenCalledTimes(1);
});
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")');
@@ -197,6 +197,12 @@ describe("NacosAdminConsole config workbench layout", () => {
expect(source).toContain("border-destructive/50 text-destructive hover:bg-destructive/10");
});
it("hides instance operations that the selected API plane does not support", () => {
expect(source).toContain('v-if="registerInstanceCapability.supported"');
expect(source).toContain('v-if="updateInstanceHealthCapability.supported"');
expect(source).toContain('v-if="deregisterInstanceCapability.supported"');
});
it("separates the service header, filtering controls, and management actions", () => {
expect(source).toContain('<header class="shrink-0 border-b bg-background">');
expect(source).toContain('class="flex flex-wrap items-center gap-x-4 gap-y-2 border-t bg-muted/30 px-4 py-2"');
@@ -10,6 +10,15 @@ describe("NacosRoleAccessControl", () => {
expect(source).toContain("v-else-if=\"tab === 'roles' && selectedRole\"");
});
it("places the contextual create action beside search instead of duplicating refresh", () => {
expect(source).toContain(":aria-label=\"tab === 'users' ? t('nacos.createUser') : t('nacos.accessCreateRole')\"");
expect(source).toContain("@click=\"tab === 'users' ? openCreateUser() : openCreateRole()\"");
expect(source).toContain("<UserPlus v-if=\"tab === 'users'\"");
expect(source).toContain("<ShieldPlus v-else");
expect(source).not.toContain(":aria-label=\"t('nacos.refresh')\"");
expect(source).not.toContain('class="h-8 w-full"');
});
it("keeps role creation complete with per-namespace permissions", () => {
expect(source).toContain("roleForm.newUsers");
expect(source).toContain("moveNamespacesToGranted");
@@ -20,6 +29,52 @@ describe("NacosRoleAccessControl", () => {
expect(source).toContain('kind: "createRole"');
});
it("keeps the selected role identity when opening the delete dialog", () => {
expect(source).toContain("resetRoleForm(selectedRole.value.role)");
});
it("makes the exact role-name confirmation requirement explicit before deletion", () => {
expect(source).toContain("const roleDeleteConfirmed = computed(() => roleForm.confirmation === roleForm.role)");
expect(source).toContain('t("nacos.accessDeleteRoleConfirmationLabel")');
expect(source).toContain('t("nacos.accessDeleteRoleConfirmationHint")');
expect(source).toContain("roleDialog === 'delete' && !roleDeleteConfirmed");
});
it("requires the exact username before deleting any user", () => {
expect(source).toContain("const userDeleteConfirmed = computed(() => userForm.confirmation === userForm.username)");
expect(source).toContain('t("nacos.accessDeleteUserConfirmationLabel")');
expect(source).toContain('t("nacos.accessDeleteUserConfirmationHint")');
expect(source).toContain("userDialog === 'delete' && !userDeleteConfirmed");
});
it("lets the backend workflow protect only the final ROLE_ADMIN member", () => {
expect(source).toContain("if (!selectedUser.value) return");
expect(source).not.toContain('selectedUser.value.roles.includes("ROLE_ADMIN")');
expect(source).not.toContain("v-if=\"!selectedUser.roles.includes('ROLE_ADMIN')\"");
});
it("shows complete IDs only in the unassigned namespace list", () => {
expect(source).toContain('class="block truncate font-medium" :title="namespace.name"');
expect(source).toContain('class="block break-all text-[10px] leading-4 text-muted-foreground" :title="namespace.id"');
expect(source).not.toContain('<code v-if="namespace.name !== namespace.namespaceId"');
});
it("uses a wider, internally scrollable namespace picker and filters by name or ID", () => {
expect(source).toContain('class="max-h-[90vh] overflow-auto sm:max-w-3xl"');
expect(source).toContain("h-[min(28rem,45vh)]");
expect(source).toContain('const id = item.id ?? item.namespaceId ?? ""');
expect(source).toContain("item.name.toLocaleLowerCase().includes(normalized) || id.toLocaleLowerCase().includes(normalized)");
expect(source).toContain(":aria-label=\"t('nacos.accessSearchNamespaces')\"");
});
it("refreshes the role namespace options after a namespace change for this connection", () => {
expect(source).toContain('import { subscribeNacosNamespacesChanged, type NacosNamespacesChangedDetail } from "@/lib/nacos/nacosNamespaceCache"');
expect(source).toContain("function handleNacosNamespacesChanged(detail: NacosNamespacesChangedDetail)");
expect(source).toContain("if (detail.connectionId === props.connectionId) void loadSnapshot()");
expect(source).toContain("stopNacosNamespacesChangedListener = subscribeNacosNamespacesChanged(handleNacosNamespacesChanged)");
expect(source).toContain("stopNacosNamespacesChangedListener?.()");
});
it("merges split permission rows before editing an existing role", () => {
expect(source).toContain("mergeNacosNamespacePermissionAssignments(managedRolePermissions.value)");
});
@@ -30,8 +85,8 @@ describe("NacosRoleAccessControl", () => {
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"');
expect(source).toContain(':disabled="saving || !userOperationWritable ||');
expect(source).toContain(':disabled="saving || !roleOperationWritable ||');
});
it("surfaces partial operations instead of hiding them in a toast", () => {
@@ -41,6 +96,13 @@ describe("NacosRoleAccessControl", () => {
expect(source).toContain("accessOperationResult");
});
it("uses the global notification for password reset results", () => {
expect(source).toContain('import { useToast } from "@/composables/useToast"');
expect(source).toContain("const { toast } = useToast()");
expect(source).toContain('toast(t("nacos.passwordResetSucceeded", { username }), 2500)');
expect(source).toContain('toast(t("nacos.passwordResetFailed", { message }), 5000)');
});
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 })\"");
@@ -95,6 +95,9 @@ const {
editNacosNamespaceDesc,
editNacosNamespaceLoading,
confirmEditNacosNamespace,
showDeleteNacosNamespaceConfirm,
deleteNacosNamespaceLoading,
confirmDeleteNacosNamespace,
showRenameMongoCollectionDialog,
renameMongoCollectionName,
renameMongoCollectionError,
@@ -195,6 +198,7 @@ watch(
showEditDatabasePropertiesDialog,
showCreateNacosNamespaceDialog,
showEditNacosNamespaceDialog,
showDeleteNacosNamespaceConfirm,
showRenameMongoCollectionDialog,
showCloneMongoCollectionDialog,
showCreateMongoIndexDialog,
@@ -741,6 +745,26 @@ watch(
</DialogContent>
</Dialog>
<Dialog v-model:open="showDeleteNacosNamespaceConfirm">
<DialogContent class="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>{{ t("nacos.deleteNamespace") }}</DialogTitle>
</DialogHeader>
<div class="flex items-start gap-3 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm">
<AlertTriangle class="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<p class="min-w-0 break-words text-muted-foreground [overflow-wrap:anywhere]">
{{ t("nacos.deleteNamespaceDescription", { name: node.nacosNamespaceName || node.label, id: node.nacosNamespace || "public" }) }}
</p>
</div>
<DialogFooter>
<Button variant="outline" :disabled="deleteNacosNamespaceLoading" @click="showDeleteNacosNamespaceConfirm = false">{{ t("dangerDialog.cancel") }}</Button>
<Button variant="destructive" :disabled="deleteNacosNamespaceLoading" @click="confirmDeleteNacosNamespace">
{{ deleteNacosNamespaceLoading ? t("nacos.deletingNamespace") : t("dangerDialog.confirm") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="showCreateSchemaDialog">
<DialogContent class="sm:max-w-[400px]">
<DialogHeader>
@@ -254,6 +254,8 @@ import {
editNacosNamespaceName,
editNacosNamespaceDesc,
editNacosNamespaceLoading,
showDeleteNacosNamespaceConfirm,
deleteNacosNamespaceLoading,
createDatabaseCharsetOptions,
createDatabaseCollationsByCharset,
createDatabaseCharsetLoading,
@@ -497,6 +499,8 @@ const {
confirmCreateNacosNamespace,
openEditNacosNamespaceDialog,
confirmEditNacosNamespace,
openDeleteNacosNamespaceConfirm,
confirmDeleteNacosNamespace,
dropMongoCollection,
dropMilvusCollection,
dropMongoIndex,
@@ -2898,6 +2902,8 @@ const canEditNacosNamespace = computed(() => {
return config?.db_type === "nacos" && !config.read_only;
});
const canDeleteNacosNamespace = computed(() => canEditNacosNamespace.value);
const isDuckDbConnection = computed(() => {
const config = activeNode.value.connectionId ? connectionStore.getConfig(activeNode.value.connectionId) : undefined;
return activeNode.value.type === "connection" && config?.db_type === "duckdb" && connectionNamespaceCreationTarget(config) === "attach";
@@ -4450,6 +4456,9 @@ function databaseSpecificDialogCapabilities() {
editNacosNamespaceDesc,
editNacosNamespaceLoading,
confirmEditNacosNamespace,
showDeleteNacosNamespaceConfirm,
deleteNacosNamespaceLoading,
confirmDeleteNacosNamespace,
showRenameMongoCollectionDialog,
renameMongoCollectionName,
renameMongoCollectionError,
@@ -5114,6 +5123,16 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean {
});
items.push({ label: "", separator: true });
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
if (canDeleteNacosNamespace.value) {
items.push({ label: "", separator: true });
items.push({
label: t("nacos.deleteNamespace"),
action: openDeleteNacosNamespaceConfirm,
icon: Trash2,
shortcut: shortcutDelete,
variant: "destructive" as const,
});
}
return true;
}
@@ -96,6 +96,8 @@ export const showEditNacosNamespaceDialog = ref(false);
export const editNacosNamespaceName = ref("");
export const editNacosNamespaceDesc = ref("");
export const editNacosNamespaceLoading = ref(false);
export const showDeleteNacosNamespaceConfirm = ref(false);
export const deleteNacosNamespaceLoading = ref(false);
export const createDatabaseCharsetOptions = ref<string[]>(fallbackCreateDatabaseCharset.charsets);
export const createDatabaseCollationsByCharset = ref<Record<string, string[]>>(fallbackCreateDatabaseCharset.collationsByCharset);
export const createDatabaseCharsetLoading = ref(false);