mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 08:05:46 +08:00
Add filter-based topology bulk writes and refine API key scopes UX.
Filter add/layout/remove keeps MCP payloads small; tokens default MCP scopes and edit them in a modal with a compact help tip. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
bf0dfd66d8
commit
2d92dde8e5
18 changed files with 1294 additions and 257 deletions
|
|
@ -11,6 +11,7 @@ const en = {
|
|||
pagerMeta: "{{total}} items · page {{page}}/{{pages}}",
|
||||
opFailed: "Operation failed",
|
||||
empty: "-",
|
||||
cancel: "Cancel",
|
||||
},
|
||||
workbench: {
|
||||
title: "Workbench",
|
||||
|
|
@ -476,8 +477,8 @@ const en = {
|
|||
colStatus: "Status",
|
||||
colIp: "IP",
|
||||
apiKeysTitle: "API Key management",
|
||||
apiKeysHint:
|
||||
"Create long-lived tokens for MCP/scripts. The secret is shown only once. Admins can issue keys for other users. Pick scopes; topology drawing needs ne:write.",
|
||||
apiKeysHelp:
|
||||
"The web UI uses login sessions, not API keys — keys are for MCP/scripts. Defaults on create: alarms:read + ne:read + ne:write + ne:exec; refine later with Edit scopes (takes effect immediately, no need to regenerate). Secret is shown once. Admins may issue for other users; scopes cannot exceed the owner's. Unused scopes are reserved for future MCP tools.",
|
||||
tokenName: "Name",
|
||||
expiresIn: "Expiry",
|
||||
expire7d: "7 days",
|
||||
|
|
@ -489,6 +490,7 @@ const en = {
|
|||
tokenOwnerSelf: "Myself ({{user}})",
|
||||
createToken: "Create key",
|
||||
tokenCreated: "API key created",
|
||||
tokenUpdated: "Scopes updated",
|
||||
tokenRevoked: "Revoked",
|
||||
tokenOnceHint: "Copy and store this secret now; it will not be shown again:",
|
||||
copyToken: "Copy",
|
||||
|
|
@ -501,24 +503,21 @@ const en = {
|
|||
tokenStatusRevoked: "Revoked",
|
||||
revokeToken: "Revoke",
|
||||
revokeConfirm: "Revoke this API key?",
|
||||
scopesTitle: "Scopes",
|
||||
scopesHint:
|
||||
"Scopes are stored on the token. Without Inherit, pick at least one. MCP default omits ne:write so write tools stay hidden from the agent.",
|
||||
scopesInherit: "Inherit all owner scopes",
|
||||
scopesInheritShort: "Inherit owner",
|
||||
editScopes: "Edit scopes",
|
||||
saveScopes: "Save scopes",
|
||||
scopesUnsetLegacy: "Unrestricted (same as owner)",
|
||||
scopesCol: "Scopes",
|
||||
scopesRequired: "Select at least one scope, or inherit all owner scopes",
|
||||
scopesRequired: "Select at least one scope",
|
||||
scopesNoneAvailable: "This owner has no grantable scopes",
|
||||
scopePresetMcp: "MCP default (read + CLI)",
|
||||
scopePresetTopoWrite: "MCP + topology write",
|
||||
scopeAlarmsRead: "alarms:read Alarms read",
|
||||
scopeNeRead: "ne:read NE / topology read",
|
||||
scopeNeWrite: "ne:write NE / topology write (draw)",
|
||||
scopeNeExec: "ne:exec Managed NE exec",
|
||||
scopeWebcrt: "webcrt:session WebCRT",
|
||||
scopeSql: "sql:query SQL",
|
||||
scopeAdminUsers: "admin:users User admin",
|
||||
scopeOpsWrite: "ops:write Ops write",
|
||||
scopeAlarmsRead: "alarms:read — Alarms read (netx MCP)",
|
||||
scopeNeRead: "ne:read — NE / topology read (MCP)",
|
||||
scopeNeWrite: "ne:write — NE / topology write / draw (MCP)",
|
||||
scopeNeExec: "ne:exec — Managed NE CLI (netx MCP)",
|
||||
scopeSql: "sql:query — UME SQL (netx MCP)",
|
||||
scopeWebcrt: "webcrt:session — WebCRT (reserved MCP)",
|
||||
scopeAdminUsers: "admin:users — User admin (reserved MCP)",
|
||||
scopeOpsWrite: "ops:write — Ops write / subscriptions (reserved MCP)",
|
||||
|
||||
forceChangeTitle: "Change initial password",
|
||||
forceChangeHint: "Account {{user}} is still using the default password. You must change it before continuing.",
|
||||
oldPassword: "Current password",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const zh = {
|
|||
pagerMeta: "共 {{total}} 条 · 第 {{page}}/{{pages}} 页",
|
||||
opFailed: "操作失败",
|
||||
empty: "-",
|
||||
cancel: "取消",
|
||||
},
|
||||
workbench: {
|
||||
title: "工作台",
|
||||
|
|
@ -473,7 +474,8 @@ const zh = {
|
|||
colStatus: "状态码",
|
||||
colIp: "IP",
|
||||
apiKeysTitle: "API Key 管理",
|
||||
apiKeysHint: "生成长期 Token 供 MCP/脚本调用;明文仅创建时显示一次。管理员可为其他用户签发。可勾选权限;拓扑画图需 ne:write。",
|
||||
apiKeysHelp:
|
||||
"网页登录不用 API Key,Key 给 MCP/脚本用。生成时默认 alarms:read + ne:read + ne:write + ne:exec,之后用「改权限」增减(保存即生效,不必重生明文)。明文仅创建时显示一次。管理员可代其他用户签发,权限不能超过所属用户已有能力;未接 MCP 的项为预留。",
|
||||
tokenName: "名称",
|
||||
expiresIn: "有效期",
|
||||
expire7d: "7 天",
|
||||
|
|
@ -485,6 +487,7 @@ const zh = {
|
|||
tokenOwnerSelf: "自己({{user}})",
|
||||
createToken: "生成 Key",
|
||||
tokenCreated: "API Key 已生成",
|
||||
tokenUpdated: "权限已更新",
|
||||
tokenRevoked: "已吊销",
|
||||
tokenOnceHint: "请立即复制保存,关闭后无法再次查看明文:",
|
||||
copyToken: "复制",
|
||||
|
|
@ -497,23 +500,20 @@ const zh = {
|
|||
tokenStatusRevoked: "已吊销",
|
||||
revokeToken: "吊销",
|
||||
revokeConfirm: "确定吊销该 API Key?",
|
||||
scopesTitle: "权限范围",
|
||||
scopesHint: "勾选后写入 Token;不勾选「继承」时至少选一项。默认 MCP 不含 ne:write,写工具对 Agent 不可见。",
|
||||
scopesInherit: "继承所属用户全部权限",
|
||||
scopesInheritShort: "继承用户",
|
||||
editScopes: "改权限",
|
||||
saveScopes: "保存权限",
|
||||
scopesUnsetLegacy: "未限定(等同所属用户)",
|
||||
scopesCol: "权限",
|
||||
scopesRequired: "请至少勾选一项权限,或选择继承用户全部权限",
|
||||
scopesRequired: "请至少勾选一项权限",
|
||||
scopesNoneAvailable: "当前所属用户没有可授予的权限",
|
||||
scopePresetMcp: "MCP 默认(只读+CLI)",
|
||||
scopePresetTopoWrite: "MCP + 拓扑写",
|
||||
scopeAlarmsRead: "alarms:read 告警只读",
|
||||
scopeNeRead: "ne:read 网元/拓扑只读",
|
||||
scopeNeWrite: "ne:write 网元/拓扑写入(含画图)",
|
||||
scopeNeExec: "ne:exec 托管网元执行命令",
|
||||
scopeWebcrt: "webcrt:session WebCRT",
|
||||
scopeSql: "sql:query SQL 查询",
|
||||
scopeAdminUsers: "admin:users 用户管理",
|
||||
scopeOpsWrite: "ops:write 运维写入",
|
||||
scopeAlarmsRead: "alarms:read — 告警只读(netx MCP)",
|
||||
scopeNeRead: "ne:read — 网元/拓扑只读(MCP)",
|
||||
scopeNeWrite: "ne:write — 网元/拓扑写入、画图(MCP)",
|
||||
scopeNeExec: "ne:exec — 托管网元 CLI(netx MCP)",
|
||||
scopeSql: "sql:query — UME SQL(netx MCP)",
|
||||
scopeWebcrt: "webcrt:session — WebCRT(预留 MCP)",
|
||||
scopeAdminUsers: "admin:users — 用户管理(预留 MCP)",
|
||||
scopeOpsWrite: "ops:write — 运维写/订阅(预留 MCP)",
|
||||
forceChangeTitle: "请修改初始密码",
|
||||
forceChangeHint: "账号 {{user}} 仍在使用默认密码,登录前必须先修改。",
|
||||
oldPassword: "当前密码",
|
||||
|
|
|
|||
|
|
@ -1436,6 +1436,165 @@ pre {
|
|||
color: #475569;
|
||||
}
|
||||
|
||||
.token-page__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.help-q {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #94a3b8;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
cursor: help;
|
||||
user-select: none;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.help-q:hover,
|
||||
.help-q:focus-visible {
|
||||
border-color: #64748b;
|
||||
color: #334155;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.help-q__tip {
|
||||
display: none;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(100% + 8px);
|
||||
z-index: 90;
|
||||
width: max-content;
|
||||
max-width: min(360px, 70vw);
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background: #0f172a;
|
||||
color: #f8fafc;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 1.45;
|
||||
white-space: normal;
|
||||
text-align: left;
|
||||
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.22);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.help-q__tip::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 8px;
|
||||
border: 5px solid transparent;
|
||||
border-bottom-color: #0f172a;
|
||||
}
|
||||
|
||||
.help-q:hover .help-q__tip,
|
||||
.help-q:focus-visible .help-q__tip {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.token-scopes-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.token-scopes-modal__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(15, 27, 45, 0.45);
|
||||
}
|
||||
|
||||
.token-scopes-modal__panel {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(480px, 100%);
|
||||
max-height: min(80vh, 640px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 16px 18px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #cdd6e2;
|
||||
background: #fff;
|
||||
box-shadow: 0 16px 40px rgba(15, 27, 45, 0.18);
|
||||
}
|
||||
|
||||
.token-scopes-modal__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.token-scopes-modal__head h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
color: var(--nm-brand, #0f1b2d);
|
||||
}
|
||||
|
||||
.token-scopes-modal__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.token-scopes-modal__list > li + li {
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.token-scopes-modal__row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.token-scopes-modal__row:hover {
|
||||
background: #eef2f7;
|
||||
}
|
||||
|
||||
.token-scopes-modal__row input {
|
||||
margin-top: 2px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.token-scopes-modal__label {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.token-scopes-modal__foot {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
|
||||
.ume-page .pt-list-actions,
|
||||
.ume-page .pt-list-table .btn-row {
|
||||
gap: 6px;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||
import { useMemo, useState, type FormEvent } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { useI18n } from "../i18n";
|
||||
import { useToast } from "../hooks/useToast";
|
||||
import { apiDelete, apiGet, apiPost } from "../services/api";
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "../services/api";
|
||||
import { formatSystemTime } from "../utils/time";
|
||||
|
||||
type TokenRow = {
|
||||
|
|
@ -33,22 +33,22 @@ const ALL_SCOPE_KEYS = [
|
|||
"ne:read",
|
||||
"ne:write",
|
||||
"ne:exec",
|
||||
"webcrt:session",
|
||||
"sql:query",
|
||||
"webcrt:session",
|
||||
"admin:users",
|
||||
"ops:write",
|
||||
] as const;
|
||||
|
||||
const MCP_DEFAULT_SCOPES = ["alarms:read", "ne:read", "ne:exec"] as const;
|
||||
const MCP_TOPO_WRITE_SCOPES = ["alarms:read", "ne:read", "ne:exec", "ne:write"] as const;
|
||||
/** Applied on create; refine later via「改权限」. */
|
||||
const CREATE_DEFAULT_SCOPES = ["alarms:read", "ne:read", "ne:exec", "ne:write"] as const;
|
||||
|
||||
const SCOPE_LABEL_KEYS: Record<(typeof ALL_SCOPE_KEYS)[number], string> = {
|
||||
"alarms:read": "auth.scopeAlarmsRead",
|
||||
"ne:read": "auth.scopeNeRead",
|
||||
"ne:write": "auth.scopeNeWrite",
|
||||
"ne:exec": "auth.scopeNeExec",
|
||||
"webcrt:session": "auth.scopeWebcrt",
|
||||
"sql:query": "auth.scopeSql",
|
||||
"webcrt:session": "auth.scopeWebcrt",
|
||||
"admin:users": "auth.scopeAdminUsers",
|
||||
"ops:write": "auth.scopeOpsWrite",
|
||||
};
|
||||
|
|
@ -80,9 +80,9 @@ export function ApiTokensPage() {
|
|||
const [name, setName] = useState("mcp");
|
||||
const [expiresInDays, setExpiresInDays] = useState(90);
|
||||
const [ownerUserId, setOwnerUserId] = useState("");
|
||||
const [inheritScopes, setInheritScopes] = useState(false);
|
||||
const [selectedScopes, setSelectedScopes] = useState<string[]>([...MCP_DEFAULT_SCOPES]);
|
||||
const [createdPlain, setCreatedPlain] = useState("");
|
||||
const [editingRow, setEditingRow] = useState<TokenRow | null>(null);
|
||||
const [editScopes, setEditScopes] = useState<string[]>([]);
|
||||
|
||||
const tokensQuery = useQuery({
|
||||
queryKey: ["apiTokens"],
|
||||
|
|
@ -99,7 +99,7 @@ export function ApiTokensPage() {
|
|||
const items = useMemo(() => tokensQuery.data?.items || [], [tokensQuery.data]);
|
||||
const users = useMemo(() => usersQuery.data?.items || [], [usersQuery.data]);
|
||||
|
||||
const availableScopes = useMemo(() => {
|
||||
const availableForCreate = useMemo(() => {
|
||||
if (ownerUserId) {
|
||||
const owner = users.find((u) => u.id === ownerUserId);
|
||||
return [...(owner?.scopes || [])].sort();
|
||||
|
|
@ -107,22 +107,26 @@ export function ApiTokensPage() {
|
|||
return [...(myScopes || [])].sort();
|
||||
}, [ownerUserId, users, myScopes]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedScopes((prev) => {
|
||||
const next = prev.filter((s) => availableScopes.includes(s));
|
||||
if (next.length) return next;
|
||||
return intersectScopes(availableScopes, MCP_DEFAULT_SCOPES);
|
||||
});
|
||||
}, [availableScopes]);
|
||||
const editAvailable = useMemo(() => {
|
||||
if (!editingRow) return [];
|
||||
if (editingRow.user_id === user?.id) return [...(myScopes || [])].sort();
|
||||
const owner = users.find((u) => u.id === editingRow.user_id);
|
||||
return [...(owner?.scopes || myScopes || [])].sort();
|
||||
}, [editingRow, user?.id, users, myScopes]);
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPost<{ token: TokenRow & { token: string } }>("/v1/api-tokens", {
|
||||
mutationFn: () => {
|
||||
const scopes = intersectScopes(availableForCreate, CREATE_DEFAULT_SCOPES);
|
||||
if (!scopes.length) {
|
||||
throw new Error(t("auth.scopesNoneAvailable"));
|
||||
}
|
||||
return apiPost<{ token: TokenRow & { token: string } }>("/v1/api-tokens", {
|
||||
name: name.trim() || "mcp",
|
||||
expires_in_days: expiresInDays,
|
||||
user_id: isAdmin && ownerUserId ? ownerUserId : undefined,
|
||||
scopes: inheritScopes ? [] : selectedScopes,
|
||||
}),
|
||||
scopes,
|
||||
});
|
||||
},
|
||||
onSuccess: async (data) => {
|
||||
setCreatedPlain(data.token.token);
|
||||
showOk(t("auth.tokenCreated"));
|
||||
|
|
@ -131,10 +135,24 @@ export function ApiTokensPage() {
|
|||
onError: (e) => showError(String(e instanceof Error ? e.message : e)),
|
||||
});
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: (payload: { id: string; scopes: string[] }) =>
|
||||
apiPatch<{ token: TokenRow }>(`/v1/api-tokens/${encodeURIComponent(payload.id)}`, {
|
||||
scopes: payload.scopes,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
showOk(t("auth.tokenUpdated"));
|
||||
setEditingRow(null);
|
||||
await qc.invalidateQueries({ queryKey: ["apiTokens"] });
|
||||
},
|
||||
onError: (e) => showError(String(e instanceof Error ? e.message : e)),
|
||||
});
|
||||
|
||||
const revokeMut = useMutation({
|
||||
mutationFn: (id: string) => apiDelete(`/v1/api-tokens/${encodeURIComponent(id)}`),
|
||||
onSuccess: async () => {
|
||||
showOk(t("auth.tokenRevoked"));
|
||||
setEditingRow(null);
|
||||
await qc.invalidateQueries({ queryKey: ["apiTokens"] });
|
||||
},
|
||||
onError: (e) => showError(String(e instanceof Error ? e.message : e)),
|
||||
|
|
@ -142,10 +160,6 @@ export function ApiTokensPage() {
|
|||
|
||||
const onCreate = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!inheritScopes && selectedScopes.length === 0) {
|
||||
showError(t("auth.scopesRequired"));
|
||||
return;
|
||||
}
|
||||
setCreatedPlain("");
|
||||
createMut.mutate();
|
||||
};
|
||||
|
|
@ -159,19 +173,24 @@ export function ApiTokensPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const toggleScope = (scope: string) => {
|
||||
setSelectedScopes((prev) =>
|
||||
const toggleEditScope = (scope: string) => {
|
||||
setEditScopes((prev) =>
|
||||
prev.includes(scope) ? prev.filter((s) => s !== scope) : [...prev, scope].sort(),
|
||||
);
|
||||
};
|
||||
|
||||
const applyPreset = (desired: readonly string[]) => {
|
||||
setInheritScopes(false);
|
||||
setSelectedScopes(intersectScopes(availableScopes, desired));
|
||||
const startEdit = (row: TokenRow) => {
|
||||
const available =
|
||||
row.user_id === user?.id
|
||||
? [...(myScopes || [])]
|
||||
: [...(users.find((u) => u.id === row.user_id)?.scopes || myScopes || [])];
|
||||
const current = row.scopes?.length ? [...row.scopes] : [...available];
|
||||
setEditingRow(row);
|
||||
setEditScopes(intersectScopes(available, current.length ? current : CREATE_DEFAULT_SCOPES));
|
||||
};
|
||||
|
||||
const formatScopes = (scopes: string[] | undefined) => {
|
||||
if (!scopes || scopes.length === 0) return t("auth.scopesInheritShort");
|
||||
if (!scopes || scopes.length === 0) return t("auth.scopesUnsetLegacy");
|
||||
return scopes.join(", ");
|
||||
};
|
||||
|
||||
|
|
@ -179,9 +198,16 @@ export function ApiTokensPage() {
|
|||
<div className="page-stack system-page">
|
||||
<section className="panel">
|
||||
<div className="panel__toolbar">
|
||||
<h2>{t("auth.apiKeysTitle")}</h2>
|
||||
<h2 className="token-page__title">
|
||||
{t("auth.apiKeysTitle")}
|
||||
<span className="help-q" tabIndex={0} aria-label={t("auth.apiKeysHelp")}>
|
||||
?
|
||||
<span className="help-q__tip" role="tooltip">
|
||||
{t("auth.apiKeysHelp")}
|
||||
</span>
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
<p className="panel__hint">{t("auth.apiKeysHint")}</p>
|
||||
|
||||
<div className="pt-list">
|
||||
<form className="token-create" onSubmit={onCreate}>
|
||||
|
|
@ -223,52 +249,6 @@ export function ApiTokensPage() {
|
|||
{t("auth.createToken")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="token-scopes">
|
||||
<div className="token-scopes__head">
|
||||
<strong>{t("auth.scopesTitle")}</strong>
|
||||
<span className="panel__hint" style={{ margin: 0 }}>
|
||||
{t("auth.scopesHint")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="token-scopes__presets">
|
||||
<button type="button" className="btn--ghost btn--sm" onClick={() => applyPreset(MCP_DEFAULT_SCOPES)}>
|
||||
{t("auth.scopePresetMcp")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn--ghost btn--sm"
|
||||
onClick={() => applyPreset(MCP_TOPO_WRITE_SCOPES)}
|
||||
disabled={!availableScopes.includes("ne:write")}
|
||||
>
|
||||
{t("auth.scopePresetTopoWrite")}
|
||||
</button>
|
||||
</div>
|
||||
<label className="token-scopes__inherit">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inheritScopes}
|
||||
onChange={(e) => setInheritScopes(e.target.checked)}
|
||||
/>
|
||||
{t("auth.scopesInherit")}
|
||||
</label>
|
||||
<div className={`token-scopes__grid${inheritScopes ? " is-disabled" : ""}`}>
|
||||
{ALL_SCOPE_KEYS.filter((s) => availableScopes.includes(s)).map((scope) => (
|
||||
<label key={scope}>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={inheritScopes}
|
||||
checked={selectedScopes.includes(scope)}
|
||||
onChange={() => toggleScope(scope)}
|
||||
/>
|
||||
{t(SCOPE_LABEL_KEYS[scope])}
|
||||
</label>
|
||||
))}
|
||||
{!availableScopes.length ? (
|
||||
<span className="muted">{t("auth.scopesNoneAvailable")}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{createdPlain ? (
|
||||
|
|
@ -332,6 +312,14 @@ export function ApiTokensPage() {
|
|||
</td>
|
||||
<td>
|
||||
<div className="btn-row pt-list-actions table-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn--ghost"
|
||||
disabled={row.revoked || row.expired}
|
||||
onClick={() => startEdit(row)}
|
||||
>
|
||||
{t("auth.editScopes")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn--danger"
|
||||
|
|
@ -352,6 +340,60 @@ export function ApiTokensPage() {
|
|||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{editingRow ? (
|
||||
<div
|
||||
className="token-scopes-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("auth.editScopes")}
|
||||
>
|
||||
<div className="token-scopes-modal__backdrop" onClick={() => setEditingRow(null)} />
|
||||
<div className="token-scopes-modal__panel">
|
||||
<div className="token-scopes-modal__head">
|
||||
<div>
|
||||
<h3>{t("auth.editScopes")}</h3>
|
||||
<p className="panel__hint" style={{ margin: "4px 0 0" }}>
|
||||
{editingRow.name}
|
||||
{editingRow.username ? ` · ${editingRow.username}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn--ghost" onClick={() => setEditingRow(null)}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
<ul className="token-scopes-modal__list">
|
||||
{ALL_SCOPE_KEYS.filter((s) => editAvailable.includes(s)).map((scope) => (
|
||||
<li key={scope}>
|
||||
<label className="token-scopes-modal__row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editScopes.includes(scope)}
|
||||
onChange={() => toggleEditScope(scope)}
|
||||
/>
|
||||
<span className="token-scopes-modal__label">{t(SCOPE_LABEL_KEYS[scope])}</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
{!editAvailable.length ? (
|
||||
<li className="muted">{t("auth.scopesNoneAvailable")}</li>
|
||||
) : null}
|
||||
</ul>
|
||||
<div className="token-scopes-modal__foot">
|
||||
<button
|
||||
type="button"
|
||||
disabled={updateMut.isPending || editScopes.length === 0}
|
||||
onClick={() => updateMut.mutate({ id: editingRow.id, scopes: editScopes })}
|
||||
>
|
||||
{t("auth.saveScopes")}
|
||||
</button>
|
||||
<button type="button" className="btn--ghost" onClick={() => setEditingRow(null)}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,13 @@
|
|||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
|
|
@ -358,7 +367,11 @@ function RouterIcon() {
|
|||
);
|
||||
}
|
||||
|
||||
function NeNode({ data, selected }: NodeProps<Node<NeNodeData>>) {
|
||||
/** Fixed box for onlyRenderVisibleElements (xyflow skips off-screen mount when sized + handles set). */
|
||||
const TOPO_NODE_W = 160;
|
||||
const TOPO_NODE_H = 88;
|
||||
|
||||
const NeNode = memo(function NeNode({ data, selected }: NodeProps<Node<NeNodeData>>) {
|
||||
const { hideIp, hideVendor, connectMode } = useContext(TopoDisplayContext);
|
||||
const tone = nodeIconTone(data.vendor, data.managed_ne_id, data.ume_ne_id);
|
||||
const name = data.label || (!hideIp ? data.ne_ip : "") || "NE";
|
||||
|
|
@ -395,7 +408,7 @@ function NeNode({ data, selected }: NodeProps<Node<NeNodeData>>) {
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const nodeTypes = { neNode: NeNode };
|
||||
|
||||
|
|
@ -550,6 +563,13 @@ function graphToFlow(
|
|||
id: n.fabric_node_id,
|
||||
type: "neNode",
|
||||
position: { x: n.x || 0, y: n.y || 0 },
|
||||
width: TOPO_NODE_W,
|
||||
height: TOPO_NODE_H,
|
||||
// Predetermined handles let onlyRenderVisibleElements skip measuring off-screen nodes.
|
||||
handles: [
|
||||
{ type: "target", position: Position.Left, x: 0, y: TOPO_NODE_H / 2 },
|
||||
{ type: "source", position: Position.Right, x: TOPO_NODE_W, y: TOPO_NODE_H / 2 },
|
||||
],
|
||||
data: {
|
||||
label: n.label || n.name || n.ip || n.fabric_node_id,
|
||||
managed_ne_id: n.managed_ne_id || "",
|
||||
|
|
@ -3157,6 +3177,7 @@ export function TopologyPage() {
|
|||
)}
|
||||
edges={displayEdges}
|
||||
nodeTypes={nodeTypes}
|
||||
onlyRenderVisibleElements
|
||||
connectionMode={ConnectionMode.Loose}
|
||||
defaultEdgeOptions={{ type: "straight" }}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue