792 lines
36 KiB
Vue
792 lines
36 KiB
Vue
<script setup lang="ts">
|
|
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";
|
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import NacosNamespaceMultiSelect from "@/components/nacos/NacosNamespaceMultiSelect.vue";
|
|
import * as api from "@/lib/backend/api";
|
|
import { executeWithProductionContextGuard } from "@/lib/database/productionExecutionGuard";
|
|
import { subscribeNacosNamespacesChanged, type NacosNamespacesChangedDetail } from "@/lib/nacos/nacosNamespaceCache";
|
|
import { normalizeNacosNamespacesForDisplay } from "@/lib/nacos/nacosNamespaceVisibility";
|
|
import { useConnectionStore } from "@/stores/connectionStore";
|
|
import type { NacosAdminConfig, NacosConnectionInfo, NacosNamespaceInfo, NacosNamespacePrivilege, NacosOperationCapability, NacosRoleBinding, NacosUserInfo } from "@/types/nacos";
|
|
|
|
const props = defineProps<{
|
|
connectionId: string;
|
|
connectionInfo: NacosConnectionInfo | null;
|
|
readOnly?: boolean;
|
|
tab: "users" | "roles";
|
|
}>();
|
|
|
|
const { t } = useI18n();
|
|
const connectionStore = useConnectionStore();
|
|
|
|
type UserDialogMode = "create" | "edit" | "reset";
|
|
|
|
const users = ref<NacosUserInfo[]>([]);
|
|
const usersLoading = ref(false);
|
|
const usersError = ref("");
|
|
const userOperationNotice = ref<{ kind: "success" | "error"; message: string } | null>(null);
|
|
const userSearch = ref("");
|
|
const userPageNo = ref(1);
|
|
const userPageSize = ref(20);
|
|
const userTotal = ref(0);
|
|
|
|
const roles = ref<NacosRoleBinding[]>([]);
|
|
const rolesLoading = ref(false);
|
|
const rolesError = ref("");
|
|
const roleUsername = ref("");
|
|
const roleName = ref("");
|
|
const rolePageNo = ref(1);
|
|
const rolePageSize = ref(20);
|
|
const roleTotal = ref(0);
|
|
|
|
const userDialogOpen = ref(false);
|
|
const userDialogMode = ref<UserDialogMode>("create");
|
|
const userDialogError = ref("");
|
|
const userSaving = ref(false);
|
|
const userForm = ref(createUserForm());
|
|
const userFormNamespaces = ref<NacosNamespaceInfo[]>([]);
|
|
const userFormNamespacesLoading = ref(false);
|
|
const userFormNamespacesError = ref("");
|
|
const pendingUserDelete = ref<NacosUserInfo | null>(null);
|
|
const userDeleteConfirmation = ref("");
|
|
const deletingUser = ref(false);
|
|
const roleDialogOpen = ref(false);
|
|
const roleDialogError = ref("");
|
|
const roleSaving = ref(false);
|
|
const roleForm = ref({ username: "", role: "" });
|
|
const pendingRoleDelete = ref<NacosRoleBinding | null>(null);
|
|
const deletingRole = ref(false);
|
|
|
|
const rnacosAuthOpen = ref(false);
|
|
const rnacosAuthLoading = ref(false);
|
|
const rnacosCaptchaImage = ref("");
|
|
const rnacosCaptcha = ref("");
|
|
const rnacosAuthError = ref("");
|
|
let rnacosRetry: (() => Promise<void>) | null = null;
|
|
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 {
|
|
username: "",
|
|
password: "",
|
|
nickname: "",
|
|
enabled: true,
|
|
roles: [] as string[],
|
|
whitelistIsAll: true,
|
|
whitelist: [] as string[],
|
|
blacklistIsAll: false,
|
|
blacklist: [] as string[],
|
|
};
|
|
}
|
|
|
|
function operationCapability(value: NacosOperationCapability | undefined): NacosOperationCapability {
|
|
return value ?? { supported: false, reason: "notVerified" };
|
|
}
|
|
|
|
function capabilityReason(capability: NacosOperationCapability) {
|
|
if (props.readOnly) return t("nacos.capabilityReadOnly");
|
|
switch (capability.reason) {
|
|
case "implementationReadOnly":
|
|
return t("nacos.capabilityReadOnlyWrite");
|
|
case "versionUnsupported":
|
|
return t("nacos.capabilityVersionUnsupported");
|
|
case "endpointUnavailable":
|
|
return t("nacos.capabilityEndpointUnavailable");
|
|
default:
|
|
return t("nacos.capabilityNotVerified");
|
|
}
|
|
}
|
|
|
|
function isWritable(capability: NacosOperationCapability | undefined) {
|
|
return !props.readOnly && operationCapability(capability).supported;
|
|
}
|
|
|
|
async function confirmAccessMutation(reviewText: string) {
|
|
const confirmed = await executeWithProductionContextGuard({
|
|
connection: connectionStore.getConfig(props.connectionId),
|
|
database: "",
|
|
reviewText,
|
|
source: t("production.sourceAdmin"),
|
|
execute: async () => true,
|
|
});
|
|
return confirmed === true;
|
|
}
|
|
|
|
function listUsersCapability() {
|
|
return operationCapability(accessControl.value?.listUsers);
|
|
}
|
|
|
|
function createUserCapability() {
|
|
return operationCapability(accessControl.value?.createUser);
|
|
}
|
|
|
|
function updateUserCapability() {
|
|
return operationCapability(accessControl.value?.updateUser);
|
|
}
|
|
|
|
function deleteUserCapability() {
|
|
return operationCapability(accessControl.value?.deleteUser);
|
|
}
|
|
|
|
function listRolesCapability() {
|
|
return operationCapability(accessControl.value?.listRoleBindings);
|
|
}
|
|
|
|
function assignRoleCapability() {
|
|
return operationCapability(accessControl.value?.assignRole);
|
|
}
|
|
|
|
function removeRoleCapability() {
|
|
return operationCapability(accessControl.value?.removeRole);
|
|
}
|
|
|
|
function privilegeFromForm(): NacosNamespacePrivilege {
|
|
return {
|
|
// r-nacos derives this read-only state from its privilege flags; its
|
|
// update API accepts only the whitelist and blacklist options below.
|
|
enabled: true,
|
|
whitelistIsAll: userForm.value.whitelistIsAll,
|
|
whitelist: [...userForm.value.whitelist],
|
|
blacklistIsAll: userForm.value.blacklistIsAll,
|
|
blacklist: [...userForm.value.blacklist],
|
|
};
|
|
}
|
|
|
|
function captchaImageSource(image: string) {
|
|
return image.startsWith("data:") ? image : `data:image/png;base64,${image}`;
|
|
}
|
|
|
|
async function withRNacosAuthentication(action: () => Promise<void>) {
|
|
try {
|
|
await action();
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
if (!message.includes("[rnacosConsoleCaptchaRequired]")) throw error;
|
|
rnacosRetry = action;
|
|
await requestRNacosAuthentication();
|
|
}
|
|
}
|
|
|
|
async function requestRNacosAuthentication() {
|
|
rnacosAuthLoading.value = true;
|
|
rnacosAuthError.value = "";
|
|
rnacosCaptcha.value = "";
|
|
try {
|
|
const challenge = await api.nacosGetRNacosConsoleCaptcha(props.connectionId);
|
|
if (!challenge.required) {
|
|
await api.nacosLoginRNacosConsole(props.connectionId);
|
|
const retry = rnacosRetry;
|
|
rnacosRetry = null;
|
|
if (retry) await retry();
|
|
return;
|
|
}
|
|
if (!challenge.image) throw new Error(t("nacos.rnacosCaptchaUnavailable"));
|
|
rnacosCaptchaImage.value = captchaImageSource(challenge.image);
|
|
rnacosAuthOpen.value = true;
|
|
} catch (error) {
|
|
rnacosAuthError.value = error instanceof Error ? error.message : String(error);
|
|
} finally {
|
|
rnacosAuthLoading.value = false;
|
|
}
|
|
}
|
|
|
|
async function submitRNacosAuthentication() {
|
|
if (!rnacosCaptcha.value.trim()) {
|
|
rnacosAuthError.value = t("nacos.rnacosCaptchaRequired");
|
|
return;
|
|
}
|
|
rnacosAuthLoading.value = true;
|
|
rnacosAuthError.value = "";
|
|
try {
|
|
await api.nacosLoginRNacosConsole(props.connectionId, rnacosCaptcha.value);
|
|
rnacosAuthOpen.value = false;
|
|
const retry = rnacosRetry;
|
|
rnacosRetry = null;
|
|
if (retry) await retry();
|
|
} catch (error) {
|
|
rnacosAuthError.value = error instanceof Error ? error.message : String(error);
|
|
} finally {
|
|
rnacosAuthLoading.value = false;
|
|
}
|
|
}
|
|
|
|
async function loadUsers(page = userPageNo.value) {
|
|
if (!listUsersCapability().supported) return;
|
|
usersLoading.value = true;
|
|
usersError.value = "";
|
|
userPageNo.value = page;
|
|
try {
|
|
await withRNacosAuthentication(async () => {
|
|
const result = await api.nacosListUsers(props.connectionId, {
|
|
username: userSearch.value.trim() || undefined,
|
|
pageNo: userPageNo.value,
|
|
pageSize: userPageSize.value,
|
|
});
|
|
users.value = result.items;
|
|
userTotal.value = result.totalCount;
|
|
});
|
|
} catch (error) {
|
|
usersError.value = error instanceof Error ? error.message : String(error);
|
|
} finally {
|
|
usersLoading.value = false;
|
|
}
|
|
}
|
|
|
|
async function loadRoles(page = rolePageNo.value) {
|
|
if (!listRolesCapability().supported) return;
|
|
rolesLoading.value = true;
|
|
rolesError.value = "";
|
|
rolePageNo.value = page;
|
|
try {
|
|
const result = await api.nacosListRoleBindings(props.connectionId, {
|
|
username: roleUsername.value.trim() || undefined,
|
|
role: roleName.value.trim() || undefined,
|
|
pageNo: rolePageNo.value,
|
|
pageSize: rolePageSize.value,
|
|
});
|
|
roles.value = result.items;
|
|
roleTotal.value = result.totalCount;
|
|
} catch (error) {
|
|
rolesError.value = error instanceof Error ? error.message : String(error);
|
|
} finally {
|
|
rolesLoading.value = false;
|
|
}
|
|
}
|
|
|
|
async function loadUserFormNamespaces(force = false) {
|
|
if (!isRNacos.value || userFormNamespacesLoading.value || (userFormNamespaces.value.length && !force)) return;
|
|
const connectionId = props.connectionId;
|
|
const requestId = ++latestUserFormNamespacesRequestId;
|
|
userFormNamespacesLoading.value = true;
|
|
userFormNamespacesError.value = "";
|
|
try {
|
|
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 {
|
|
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();
|
|
}
|
|
|
|
function openEditUser(user: NacosUserInfo) {
|
|
userDialogMode.value = isRNacos.value ? "edit" : "reset";
|
|
userDialogError.value = "";
|
|
userOperationNotice.value = null;
|
|
const privilege = user.namespacePrivilege;
|
|
userForm.value = {
|
|
username: user.username,
|
|
password: "",
|
|
nickname: user.nickname ?? "",
|
|
enabled: user.enabled ?? true,
|
|
roles: [...user.roles],
|
|
whitelistIsAll: privilege?.whitelistIsAll ?? true,
|
|
whitelist: [...(privilege?.whitelist ?? [])],
|
|
blacklistIsAll: privilege?.blacklistIsAll ?? false,
|
|
blacklist: [...(privilege?.blacklist ?? [])],
|
|
};
|
|
userDialogOpen.value = true;
|
|
void loadUserFormNamespaces();
|
|
}
|
|
|
|
watch(
|
|
() => props.connectionId,
|
|
() => {
|
|
latestUserFormNamespacesRequestId += 1;
|
|
userFormNamespaces.value = [];
|
|
userFormNamespacesError.value = "";
|
|
userFormNamespacesLoading.value = false;
|
|
},
|
|
);
|
|
|
|
function toggleRNacosRole(role: string, checked: boolean) {
|
|
const next = new Set(userForm.value.roles);
|
|
if (checked) next.add(role);
|
|
else next.delete(role);
|
|
userForm.value.roles = [...next];
|
|
}
|
|
|
|
async function saveUser() {
|
|
if (userSaving.value) return;
|
|
const username = userForm.value.username.trim();
|
|
const resettingPassword = userDialogMode.value === "reset";
|
|
if (!username || (userDialogMode.value !== "edit" && !userForm.value.password)) {
|
|
userDialogError.value = t("nacos.userCredentialsRequired");
|
|
return;
|
|
}
|
|
if (!(await confirmAccessMutation(`${userDialogMode.value === "create" ? t("nacos.createUser") : t("nacos.save")}: ${username}`))) return;
|
|
userSaving.value = true;
|
|
userDialogError.value = "";
|
|
try {
|
|
await withRNacosAuthentication(async () => {
|
|
if (userDialogMode.value === "create") {
|
|
await api.nacosCreateUser(props.connectionId, {
|
|
username,
|
|
password: userForm.value.password,
|
|
nickname: isRNacos.value ? userForm.value.nickname.trim() || undefined : undefined,
|
|
enabled: isRNacos.value ? userForm.value.enabled : undefined,
|
|
roles: isRNacos.value ? userForm.value.roles : [],
|
|
namespacePrivilege: isRNacos.value ? privilegeFromForm() : undefined,
|
|
});
|
|
} else {
|
|
await api.nacosUpdateUser(props.connectionId, {
|
|
username,
|
|
password: userForm.value.password,
|
|
nickname: isRNacos.value ? userForm.value.nickname.trim() || undefined : undefined,
|
|
enabled: isRNacos.value ? userForm.value.enabled : undefined,
|
|
roles: isRNacos.value ? userForm.value.roles : undefined,
|
|
namespacePrivilege: isRNacos.value ? privilegeFromForm() : undefined,
|
|
});
|
|
}
|
|
userDialogOpen.value = false;
|
|
await loadUsers(1);
|
|
if (resettingPassword) {
|
|
const handledConnectionCredential = await syncCurrentConnectionPassword(username, userForm.value.password);
|
|
if (!handledConnectionCredential) {
|
|
userOperationNotice.value = { kind: "success", message: t("nacos.passwordResetSucceeded", { username }) };
|
|
}
|
|
}
|
|
});
|
|
} catch (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;
|
|
if (!(await confirmAccessMutation(`${t("nacos.deleteUser")}: ${user.username}`))) return;
|
|
deletingUser.value = true;
|
|
try {
|
|
await withRNacosAuthentication(async () => {
|
|
await api.nacosDeleteUser(props.connectionId, user.username);
|
|
pendingUserDelete.value = null;
|
|
userDeleteConfirmation.value = "";
|
|
await loadUsers(Math.min(userPageNo.value, userTotalPages.value));
|
|
});
|
|
} catch (error) {
|
|
usersError.value = error instanceof Error ? error.message : String(error);
|
|
} finally {
|
|
deletingUser.value = false;
|
|
}
|
|
}
|
|
|
|
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: "" };
|
|
roleDialogOpen.value = true;
|
|
}
|
|
|
|
async function assignRole() {
|
|
if (roleSaving.value) return;
|
|
if (!roleForm.value.username.trim() || !roleForm.value.role.trim()) {
|
|
roleDialogError.value = t("nacos.roleBindingRequired");
|
|
return;
|
|
}
|
|
if (!(await confirmAccessMutation(`${t("nacos.assignRole")}: ${roleForm.value.username.trim()} · ${roleForm.value.role.trim()}`))) return;
|
|
roleSaving.value = true;
|
|
roleDialogError.value = "";
|
|
try {
|
|
await api.nacosAssignRole(props.connectionId, { username: roleForm.value.username.trim(), role: roleForm.value.role.trim() });
|
|
roleDialogOpen.value = false;
|
|
await loadRoles(1);
|
|
} catch (error) {
|
|
roleDialogError.value = error instanceof Error ? error.message : String(error);
|
|
} finally {
|
|
roleSaving.value = false;
|
|
}
|
|
}
|
|
|
|
async function removeRole() {
|
|
const binding = pendingRoleDelete.value;
|
|
if (!binding || deletingRole.value) return;
|
|
if (!(await confirmAccessMutation(`${t("nacos.removeRole")}: ${binding.username} · ${binding.role}`))) return;
|
|
deletingRole.value = true;
|
|
try {
|
|
await api.nacosRemoveRole(props.connectionId, binding);
|
|
pendingRoleDelete.value = null;
|
|
await loadRoles(Math.min(rolePageNo.value, roleTotalPages.value));
|
|
} catch (error) {
|
|
rolesError.value = error instanceof Error ? error.message : String(error);
|
|
} finally {
|
|
deletingRole.value = false;
|
|
}
|
|
}
|
|
|
|
watch(
|
|
() => props.tab,
|
|
(tab) => {
|
|
if (tab === "users") void loadUsers(1);
|
|
else void loadRoles(1);
|
|
},
|
|
);
|
|
|
|
watch(
|
|
() => props.connectionId,
|
|
() => {
|
|
users.value = [];
|
|
roles.value = [];
|
|
userPageNo.value = 1;
|
|
rolePageNo.value = 1;
|
|
if (props.tab === "users") void loadUsers(1);
|
|
else void loadRoles(1);
|
|
},
|
|
);
|
|
|
|
onMounted(() => {
|
|
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>
|
|
<section class="flex h-full min-h-0 flex-col bg-background">
|
|
<template v-if="tab === 'users'">
|
|
<header class="flex shrink-0 flex-wrap items-center gap-2 border-b bg-muted/20 px-4 py-2.5">
|
|
<div class="relative min-w-48 max-w-xl flex-1">
|
|
<Input v-model="userSearch" class="h-8 pr-8" :placeholder="t('nacos.searchUsers')" @keyup.enter="loadUsers(1)" />
|
|
<Search class="pointer-events-none absolute right-2.5 top-2 h-4 w-4 text-muted-foreground" />
|
|
</div>
|
|
<div class="flex-1" />
|
|
<Button size="sm" variant="outline" class="h-8 w-8 px-0" :disabled="usersLoading || !listUsersCapability().supported" :title="!listUsersCapability().supported ? capabilityReason(listUsersCapability()) : t('nacos.refresh')" :aria-label="t('nacos.refresh')" @click="loadUsers(1)">
|
|
<Loader2 v-if="usersLoading" class="h-3.5 w-3.5 animate-spin" />
|
|
<RefreshCw v-else class="h-3.5 w-3.5" />
|
|
</Button>
|
|
<Button size="sm" class="h-8 gap-1.5" :disabled="!isWritable(createUserCapability())" :title="!isWritable(createUserCapability()) ? capabilityReason(createUserCapability()) : undefined" @click="openCreateUser">
|
|
<Plus class="h-3.5 w-3.5" />
|
|
{{ t("nacos.createUser") }}
|
|
</Button>
|
|
</header>
|
|
<div v-if="!listUsersCapability().supported" class="border-b bg-muted/30 px-3 py-2 text-xs text-muted-foreground">{{ capabilityReason(listUsersCapability()) }}</div>
|
|
<div v-if="usersError" class="border-b px-3 py-2 text-xs text-destructive">{{ usersError }}</div>
|
|
<div 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">
|
|
<tr class="border-b">
|
|
<th class="px-3 py-2 font-medium">{{ t("nacos.username") }}</th>
|
|
<th class="px-3 py-2 font-medium">{{ t("nacos.nickname") }}</th>
|
|
<th class="px-3 py-2 font-medium">{{ t("nacos.roles") }}</th>
|
|
<th class="px-3 py-2 font-medium">{{ t("nacos.state") }}</th>
|
|
<th class="w-32 px-3 py-2 text-right font-medium">{{ t("nacos.actions") }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="user in users" :key="user.username" class="border-b hover:bg-accent/40">
|
|
<td class="px-3 py-2 font-medium">{{ user.username }}</td>
|
|
<td class="px-3 py-2 text-muted-foreground">{{ user.nickname || "-" }}</td>
|
|
<td class="px-3 py-2">
|
|
<div class="flex flex-wrap gap-1">
|
|
<Badge v-for="role in user.roles" :key="role" variant="outline" class="font-normal">{{ isRNacos ? t(`nacos.rnacosRole${role}`) : role }}</Badge
|
|
><span v-if="!user.roles.length" class="text-muted-foreground">-</span>
|
|
</div>
|
|
</td>
|
|
<td class="px-3 py-2">
|
|
<Badge v-if="isRNacos" :variant="user.enabled === false ? 'outline' : 'secondary'" :class="user.enabled === false ? 'text-muted-foreground' : 'text-emerald-700 dark:text-emerald-300'">{{ user.enabled === false ? t("nacos.disabled") : t("nacos.enabled") }}</Badge
|
|
><span v-else class="text-muted-foreground">-</span>
|
|
</td>
|
|
<td class="px-3 py-2">
|
|
<div class="flex justify-end gap-1">
|
|
<Button size="sm" variant="ghost" class="h-7 w-7 p-0" :disabled="!isWritable(updateUserCapability())" :title="isRNacos ? t('nacos.editUser') : t('nacos.resetUserPassword')" @click="openEditUser(user)"
|
|
><Pencil v-if="isRNacos" class="h-3.5 w-3.5" /><KeyRound v-else class="h-3.5 w-3.5" /></Button
|
|
><Button size="sm" variant="ghost" class="h-7 w-7 p-0 text-destructive hover:text-destructive" :disabled="!isWritable(deleteUserCapability())" :title="t('nacos.deleteUser')" @click="openDeleteUser(user)"><Trash2 class="h-3.5 w-3.5" /></Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<div v-if="!usersLoading && !users.length" class="flex h-full min-h-48 items-center justify-center text-sm text-muted-foreground"><UserRound class="mr-2 h-4 w-4" />{{ t("nacos.noUsers") }}</div>
|
|
</div>
|
|
<footer class="flex shrink-0 items-center justify-between border-t bg-muted/10 px-4 py-2 text-xs text-muted-foreground">
|
|
<span>{{ t("nacos.total", { count: userTotal }) }}</span>
|
|
<div class="flex items-center gap-2">
|
|
<Button size="sm" variant="outline" class="h-7" :disabled="userPageNo <= 1 || usersLoading" @click="loadUsers(userPageNo - 1)">{{ t("nacos.prev") }}</Button
|
|
><span>{{ userPageNo }} / {{ userTotalPages }}</span
|
|
><Button size="sm" variant="outline" class="h-7" :disabled="userPageNo >= userTotalPages || usersLoading" @click="loadUsers(userPageNo + 1)">{{ t("nacos.next") }}</Button>
|
|
</div>
|
|
</footer>
|
|
</template>
|
|
|
|
<template v-else>
|
|
<header class="flex shrink-0 flex-wrap items-center gap-2 border-b bg-muted/20 px-4 py-2.5">
|
|
<Input v-model="roleUsername" class="h-8 min-w-44 max-w-sm flex-1" :placeholder="t('nacos.filterUsername')" @keyup.enter="loadRoles(1)" />
|
|
<Input v-model="roleName" class="h-8 min-w-44 max-w-sm flex-1" :placeholder="t('nacos.filterRole')" @keyup.enter="loadRoles(1)" />
|
|
<div class="flex-1" />
|
|
<Button size="sm" variant="outline" class="h-8 w-8 px-0" :disabled="rolesLoading || !listRolesCapability().supported" :title="!listRolesCapability().supported ? capabilityReason(listRolesCapability()) : t('nacos.refresh')" :aria-label="t('nacos.refresh')" @click="loadRoles(1)"
|
|
><Loader2 v-if="rolesLoading" class="h-3.5 w-3.5 animate-spin" /><RefreshCw v-else class="h-3.5 w-3.5"
|
|
/></Button>
|
|
<Button size="sm" class="h-8 gap-1.5" :disabled="!isWritable(assignRoleCapability())" :title="!isWritable(assignRoleCapability()) ? capabilityReason(assignRoleCapability()) : undefined" @click="openAssignRole"><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">
|
|
<table class="w-full min-w-[520px] text-sm">
|
|
<thead class="sticky top-0 z-10 bg-muted/70 text-left text-xs text-muted-foreground">
|
|
<tr class="border-b">
|
|
<th class="px-3 py-2 font-medium">{{ t("nacos.username") }}</th>
|
|
<th class="px-3 py-2 font-medium">{{ t("nacos.role") }}</th>
|
|
<th class="w-24 px-3 py-2 text-right font-medium">{{ t("nacos.actions") }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="binding in roles" :key="`${binding.username}:${binding.role}`" class="border-b hover:bg-accent/40">
|
|
<td class="px-3 py-2 font-medium">{{ binding.username }}</td>
|
|
<td class="px-3 py-2">
|
|
<Badge variant="outline" class="font-normal">{{ binding.role }}</Badge>
|
|
</td>
|
|
<td class="px-3 py-2 text-right">
|
|
<span :title="binding.role === 'ROLE_ADMIN' ? t('nacos.protectedRole') : undefined"
|
|
><Button size="sm" variant="ghost" class="h-7 w-7 p-0 text-destructive hover:text-destructive" :disabled="binding.role === 'ROLE_ADMIN' || !isWritable(removeRoleCapability())" :aria-label="t('nacos.removeRole')" @click="pendingRoleDelete = binding"
|
|
><Trash2 class="h-3.5 w-3.5" /></Button
|
|
></span>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<div v-if="!rolesLoading && !roles.length" class="flex h-full min-h-48 items-center justify-center text-sm text-muted-foreground"><ShieldCheck class="mr-2 h-4 w-4" />{{ t("nacos.noRoleBindings") }}</div>
|
|
</div>
|
|
<footer class="flex shrink-0 items-center justify-between border-t bg-muted/10 px-4 py-2 text-xs text-muted-foreground">
|
|
<span>{{ t("nacos.total", { count: roleTotal }) }}</span>
|
|
<div class="flex items-center gap-2">
|
|
<Button size="sm" variant="outline" class="h-7" :disabled="rolePageNo <= 1 || rolesLoading" @click="loadRoles(rolePageNo - 1)">{{ t("nacos.prev") }}</Button
|
|
><span>{{ rolePageNo }} / {{ roleTotalPages }}</span
|
|
><Button size="sm" variant="outline" class="h-7" :disabled="rolePageNo >= roleTotalPages || rolesLoading" @click="loadRoles(rolePageNo + 1)">{{ t("nacos.next") }}</Button>
|
|
</div>
|
|
</footer>
|
|
</template>
|
|
</section>
|
|
|
|
<Dialog v-model:open="userDialogOpen"
|
|
><DialogContent class="max-h-[88vh] overflow-auto sm:max-w-xl"
|
|
><DialogHeader
|
|
><DialogTitle>{{ t(`nacos.${userDialogMode}UserTitle`) }}</DialogTitle
|
|
><DialogDescription>{{ t(`nacos.${userDialogMode}UserDescription`) }}</DialogDescription></DialogHeader
|
|
>
|
|
<div class="space-y-4">
|
|
<div class="grid gap-1.5">
|
|
<Label for="nacos-user-name">{{ t("nacos.username") }}</Label
|
|
><Input id="nacos-user-name" v-model="userForm.username" :disabled="userDialogMode !== 'create'" autocomplete="off" />
|
|
</div>
|
|
<div class="grid gap-1.5">
|
|
<Label for="nacos-user-password">{{ t("nacos.password") }}</Label
|
|
><Input id="nacos-user-password" v-model="userForm.password" type="password" autocomplete="new-password" :placeholder="userDialogMode === 'edit' ? t('nacos.passwordUnchanged') : undefined" />
|
|
</div>
|
|
<template v-if="isRNacos && userDialogMode !== 'reset'"
|
|
><div class="grid gap-1.5">
|
|
<Label for="nacos-user-nickname">{{ t("nacos.nickname") }}</Label
|
|
><Input id="nacos-user-nickname" v-model="userForm.nickname" />
|
|
</div>
|
|
<div class="flex items-center justify-between rounded-md border px-3 py-2">
|
|
<div>
|
|
<div class="text-sm font-medium">{{ t("nacos.enabled") }}</div>
|
|
<div class="text-xs text-muted-foreground">{{ t("nacos.rnacosUserEnabledHint") }}</div>
|
|
</div>
|
|
<Switch v-model="userForm.enabled" />
|
|
</div>
|
|
<fieldset class="space-y-2 rounded-md border p-3">
|
|
<legend class="px-1 text-sm font-medium">{{ t("nacos.roles") }}</legend>
|
|
<label v-for="role in ['0', '1', '2']" :key="role" class="flex items-start gap-2 text-sm"
|
|
><input type="checkbox" :checked="userForm.roles.includes(role)" @change="toggleRNacosRole(role, ($event.target as HTMLInputElement).checked)" /><span
|
|
><span class="font-medium">{{ t(`nacos.rnacosRole${role}`) }}</span
|
|
><span class="ml-2 text-xs text-muted-foreground">{{ t(`nacos.rnacosRole${role}Hint`) }}</span></span
|
|
></label
|
|
>
|
|
</fieldset>
|
|
<fieldset class="space-y-3 rounded-md border p-3">
|
|
<legend class="px-1 text-sm font-medium">{{ t("nacos.namespacePrivileges") }}</legend>
|
|
<div class="space-y-1.5">
|
|
<div class="flex items-center justify-between">
|
|
<Label>{{ t("nacos.namespaceWhitelist") }}</Label
|
|
><label class="flex items-center gap-2 text-xs text-muted-foreground"><Switch v-model="userForm.whitelistIsAll" size="sm" />{{ t("nacos.allNamespaces") }}</label>
|
|
</div>
|
|
<NacosNamespaceMultiSelect v-if="!userForm.whitelistIsAll" v-model="userForm.whitelist" :namespaces="userFormNamespaces" :loading="userFormNamespacesLoading" :error="userFormNamespacesError" @retry="loadUserFormNamespaces(true)" />
|
|
</div>
|
|
<div class="space-y-1.5">
|
|
<div class="flex items-center justify-between">
|
|
<Label>{{ t("nacos.namespaceBlacklist") }}</Label
|
|
><label class="flex items-center gap-2 text-xs text-muted-foreground"><Switch v-model="userForm.blacklistIsAll" size="sm" />{{ t("nacos.allNamespaces") }}</label>
|
|
</div>
|
|
<NacosNamespaceMultiSelect v-if="!userForm.blacklistIsAll" v-model="userForm.blacklist" :namespaces="userFormNamespaces" :loading="userFormNamespacesLoading" :error="userFormNamespacesError" @retry="loadUserFormNamespaces(true)" />
|
|
</div></fieldset
|
|
></template>
|
|
<p v-if="userDialogError" class="text-sm text-destructive">{{ userDialogError }}</p>
|
|
</div>
|
|
<DialogFooter
|
|
><Button variant="outline" :disabled="userSaving" @click="userDialogOpen = false">{{ t("nacos.cancel") }}</Button
|
|
><Button :disabled="userSaving" @click="saveUser"><Loader2 v-if="userSaving" class="mr-2 h-4 w-4 animate-spin" />{{ t("nacos.save") }}</Button></DialogFooter
|
|
></DialogContent
|
|
></Dialog
|
|
>
|
|
|
|
<Dialog v-model:open="roleDialogOpen"
|
|
><DialogContent class="sm:max-w-md"
|
|
><DialogHeader
|
|
><DialogTitle>{{ t("nacos.assignRoleTitle") }}</DialogTitle
|
|
><DialogDescription>{{ t("nacos.assignRoleDescription") }}</DialogDescription></DialogHeader
|
|
>
|
|
<div class="space-y-4">
|
|
<div class="grid gap-1.5">
|
|
<Label for="nacos-role-user">{{ t("nacos.username") }}</Label
|
|
><Input id="nacos-role-user" v-model="roleForm.username" />
|
|
</div>
|
|
<div class="grid gap-1.5">
|
|
<Label for="nacos-role-name">{{ t("nacos.role") }}</Label
|
|
><Input id="nacos-role-name" v-model="roleForm.role" placeholder="ROLE_USER" />
|
|
</div>
|
|
<p v-if="roleDialogError" class="text-sm text-destructive">{{ roleDialogError }}</p>
|
|
</div>
|
|
<DialogFooter
|
|
><Button variant="outline" :disabled="roleSaving" @click="roleDialogOpen = false">{{ t("nacos.cancel") }}</Button
|
|
><Button :disabled="roleSaving" @click="assignRole"><Loader2 v-if="roleSaving" class="mr-2 h-4 w-4 animate-spin" />{{ t("nacos.assignRole") }}</Button></DialogFooter
|
|
></DialogContent
|
|
></Dialog
|
|
>
|
|
|
|
<Dialog :open="!!pendingUserDelete" @update:open="!$event && closeDeleteUser()"
|
|
><DialogContent class="sm:max-w-md"
|
|
><DialogHeader
|
|
><DialogTitle>{{ t("nacos.deleteUserTitle") }}</DialogTitle
|
|
><DialogDescription>{{ t("nacos.deleteUserDescription", { username: pendingUserDelete?.username }) }}</DialogDescription></DialogHeader
|
|
>
|
|
<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
|
|
>
|
|
<Dialog :open="!!pendingRoleDelete" @update:open="!$event && (pendingRoleDelete = null)"
|
|
><DialogContent class="sm:max-w-md"
|
|
><DialogHeader
|
|
><DialogTitle>{{ t("nacos.removeRoleTitle") }}</DialogTitle
|
|
><DialogDescription>{{ t("nacos.removeRoleDescription", pendingRoleDelete ?? {}) }}</DialogDescription></DialogHeader
|
|
><DialogFooter
|
|
><Button variant="outline" :disabled="deletingRole" @click="pendingRoleDelete = null">{{ t("nacos.cancel") }}</Button
|
|
><Button variant="destructive" :disabled="deletingRole" @click="removeRole"><Loader2 v-if="deletingRole" class="mr-2 h-4 w-4 animate-spin" />{{ t("nacos.removeRole") }}</Button></DialogFooter
|
|
></DialogContent
|
|
></Dialog
|
|
>
|
|
<Dialog v-model:open="rnacosAuthOpen"
|
|
><DialogContent class="sm:max-w-sm"
|
|
><DialogHeader
|
|
><DialogTitle>{{ t("nacos.rnacosConsoleAuthTitle") }}</DialogTitle
|
|
><DialogDescription>{{ t("nacos.rnacosAccessControlAuthDescription") }}</DialogDescription></DialogHeader
|
|
>
|
|
<div class="space-y-3">
|
|
<img v-if="rnacosCaptchaImage" :src="rnacosCaptchaImage" class="h-16 w-full rounded border object-contain" :alt="t('nacos.rnacosCaptchaLabel')" /><Input v-model="rnacosCaptcha" :placeholder="t('nacos.rnacosCaptchaPlaceholder')" @keyup.enter="submitRNacosAuthentication" />
|
|
<p v-if="rnacosAuthError" class="text-sm text-destructive">{{ rnacosAuthError }}</p>
|
|
</div>
|
|
<DialogFooter
|
|
><Button variant="outline" :disabled="rnacosAuthLoading" @click="requestRNacosAuthentication">{{ t("nacos.rnacosRefreshCaptcha") }}</Button
|
|
><Button :disabled="rnacosAuthLoading" @click="submitRNacosAuthentication"><Loader2 v-if="rnacosAuthLoading" class="mr-2 h-4 w-4 animate-spin" />{{ t("nacos.rnacosConsoleAuthSubmit") }}</Button></DialogFooter
|
|
></DialogContent
|
|
></Dialog
|
|
>
|
|
</template>
|