feat(auth): add local login, audit, API keys, and system admin UI

Gate netx Web/API/WebCRT with JWT and per-user API tokens, bootstrap an admin with forced password change, and expose users/audit/API-key management under a System section. MCP can reuse data/auth/mcp_token without extra env for local labs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
hansjone 2026-07-30 02:34:25 +00:00
parent 14f14d34bd
commit 6d4cd741ef
35 changed files with 2699 additions and 21 deletions

View file

@ -8,16 +8,38 @@ import { NePage } from "./pages/NePage";
import { UmePage } from "./pages/UmePage";
import { WebcrtPage } from "./pages/WebcrtPage";
import { TopologyPage } from "./pages/TopologyPage";
import { LoginPage } from "./pages/LoginPage";
import { UsersPage } from "./pages/UsersPage";
import { AuditPage } from "./pages/AuditPage";
import { ApiTokensPage } from "./pages/ApiTokensPage";
import { ForceChangePasswordPage } from "./pages/ForceChangePasswordPage";
import { fetchIntegrationStatus } from "./services/api";
import { useAuth } from "./auth/AuthContext";
function App() {
function ProtectedApp() {
const { ready, user } = useAuth();
const integrationsQuery = useQuery({
queryKey: queryKeys.integrationsStatus,
queryFn: fetchIntegrationStatus,
refetchInterval: 5000,
staleTime: 2000,
enabled: ready && Boolean(user) && !user?.must_change_password,
});
if (!ready) {
return (
<div className="login-page">
<div className="login-card">Loading…</div>
</div>
);
}
if (!user) {
return <Navigate to="/login" replace />;
}
if (user.must_change_password) {
return <ForceChangePasswordPage />;
}
return (
<AppLayout
connections={{
@ -53,10 +75,22 @@ function App() {
<Route path="/collect" element={<CollectPage />} />
<Route path="/webcrt" element={<WebcrtPage />} />
<Route path="/topology" element={<TopologyPage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/audit" element={<AuditPage />} />
<Route path="/api-keys" element={<ApiTokensPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AppLayout>
);
}
function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/*" element={<ProtectedApp />} />
</Routes>
);
}
export default App;

View file

@ -0,0 +1,108 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { apiGet, apiPost, clearAuthToken, getAuthToken, setAuthToken } from "../services/api";
export type AuthUser = {
id: string;
username: string;
role: string;
is_active: boolean;
must_change_password?: boolean;
created_by?: string;
created_at?: string | null;
updated_at?: string | null;
};
type AuthState = {
ready: boolean;
token: string | null;
user: AuthUser | null;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refreshMe: () => Promise<void>;
isAdmin: boolean;
};
const AuthContext = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [ready, setReady] = useState(false);
const [token, setToken] = useState<string | null>(() => getAuthToken());
const [user, setUser] = useState<AuthUser | null>(null);
const refreshMe = useCallback(async () => {
const tok = getAuthToken();
if (!tok) {
setToken(null);
setUser(null);
return;
}
try {
const data = await apiGet<{ user: AuthUser }>("/v1/auth/me");
setToken(tok);
setUser(data.user);
} catch {
clearAuthToken();
setToken(null);
setUser(null);
}
}, []);
useEffect(() => {
void (async () => {
await refreshMe();
setReady(true);
})();
}, [refreshMe]);
const login = useCallback(async (username: string, password: string) => {
const data = await apiPost<{ access_token: string; user: AuthUser }>("/v1/auth/login", {
username,
password,
});
setAuthToken(data.access_token);
setToken(data.access_token);
setUser(data.user);
}, []);
const logout = useCallback(async () => {
try {
if (getAuthToken()) {
await apiPost("/v1/auth/logout", {});
}
} catch {
// ignore
}
clearAuthToken();
setToken(null);
setUser(null);
}, []);
const value = useMemo<AuthState>(
() => ({
ready,
token,
user,
login,
logout,
refreshMe,
isAdmin: user?.role === "admin",
}),
[ready, token, user, login, logout, refreshMe],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthState {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth outside AuthProvider");
return ctx;
}

View file

@ -3,7 +3,7 @@
*/
export type ModuleIconTone = "blue" | "green" | "amber" | "slate";
export type WorkbenchSection = "monitoring" | "operations";
export type WorkbenchSection = "monitoring" | "operations" | "system";
export type ModuleDefinition = {
moduleId: string;
@ -13,6 +13,7 @@ export type ModuleDefinition = {
descKey?: string;
iconTone: ModuleIconTone;
titleKey: string;
adminOnly?: boolean;
};
export const MODULES: readonly ModuleDefinition[] = [
@ -61,7 +62,35 @@ export const MODULES: readonly ModuleDefinition[] = [
iconTone: "blue",
titleKey: "layout.titleTopology",
},
] as const;
{
moduleId: "users",
path: "/users",
section: "system",
labelKey: "workbench.cards.users",
descKey: "workbench.cards.usersDesc",
iconTone: "slate",
titleKey: "layout.titleUsers",
adminOnly: true,
},
{
moduleId: "audit",
path: "/audit",
section: "system",
labelKey: "workbench.cards.audit",
descKey: "workbench.cards.auditDesc",
iconTone: "amber",
titleKey: "layout.titleAudit",
},
{
moduleId: "api-keys",
path: "/api-keys",
section: "system",
labelKey: "workbench.cards.apiKeys",
descKey: "workbench.cards.apiKeysDesc",
iconTone: "green",
titleKey: "layout.titleApiKeys",
},
] as const satisfies readonly ModuleDefinition[];
export function getModuleById(moduleId: string): ModuleDefinition | undefined {
return MODULES.find((m) => m.moduleId === moduleId);

View file

@ -19,6 +19,7 @@ const en = {
openModule: "Open or switch to this module tab",
monitoring: "Monitoring",
operations: "Operations",
system: "System",
cards: {
umeSync: "UME Sync",
umeSyncDesc: "UME alarms, subscription & inventory",
@ -30,6 +31,12 @@ const en = {
webcrtDesc: "Interactive browser login to connected NEs (SSH/Telnet)",
topology: "Topology",
topologyDesc: "Drag NE topology maps; discover links via LLDP/CDP",
users: "Users",
usersDesc: "Admin: create, disable users and reset passwords",
audit: "Audit",
auditDesc: "View login and operation records",
apiKeys: "API Keys",
apiKeysDesc: "Issue MCP/script tokens per user with expiry",
},
},
layout: {
@ -39,6 +46,9 @@ const en = {
titleCollect: "Batch Collect",
titleWebcrt: "WebCRT",
titleTopology: "Topology",
titleUsers: "Users",
titleAudit: "Audit",
titleApiKeys: "API Keys",
navUme: "UME",
netxApi: "netx api",
oclawBridge: "oclaw WSS",
@ -47,6 +57,79 @@ const en = {
langZh: "中文",
langEn: "English",
},
auth: {
loginTitle: "Sign in to NetX",
loginHint: "Use a local account to access the ops platform",
username: "Username",
password: "Password",
login: "Sign in",
loggingIn: "Signing in…",
loginFailed: "Login failed",
logout: "Sign out",
usersTitle: "User management",
usersHint: "Only admins can create and manage local accounts.",
addUser: "Add user",
role: "Role",
roleAdmin: "Admin",
roleUser: "User",
status: "Status",
active: "Active",
disabled: "Disabled",
enable: "Enable",
disable: "Disable",
actions: "Actions",
newPassword: "New password",
resetPassword: "Reset password",
userCreated: "User created",
userUpdated: "User updated",
auditTitle: "Audit log",
auditHintAdmin: "View login and operation records for all users.",
auditHintUser: "View your own operation records.",
filterUsername: "Filter username",
filterAction: "Filter action",
colTime: "Time",
colUser: "User",
colAction: "Action",
colMethod: "Method",
colPath: "Path",
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.",
tokenName: "Name",
expiresIn: "Expiry",
expire7d: "7 days",
expire30d: "30 days",
expire90d: "90 days",
expire365d: "1 year",
expireNever: "Never",
tokenOwner: "Owner",
tokenOwnerSelf: "Myself ({{user}})",
createToken: "Create key",
tokenCreated: "API key created",
tokenRevoked: "Revoked",
tokenOnceHint: "Copy and store this secret now; it will not be shown again:",
copyToken: "Copy",
tokenCopied: "Copied",
tokenCopyFailed: "Copy failed",
expiresAt: "Expires",
lastUsed: "Last used",
tokenStatusActive: "Active",
tokenStatusExpired: "Expired",
tokenStatusRevoked: "Revoked",
revokeToken: "Revoke",
revokeConfirm: "Revoke this API key?",
forceChangeTitle: "Change initial password",
forceChangeHint: "Account {{user}} is still using the default password. You must change it before continuing.",
oldPassword: "Current password",
confirmPassword: "Confirm new password",
savePassword: "Save new password",
savingPassword: "Saving…",
passwordTooShort: "New password must be at least 6 characters",
passwordMismatch: "New passwords do not match",
passwordMustChange: "New password must differ from the default/old password",
},
collect: {
create: {
title: "New collection job",

View file

@ -19,6 +19,7 @@ const zh = {
openModule: "打开或切换到该模块页签",
monitoring: "监控",
operations: "运维",
system: "系统管理",
cards: {
umeSync: "UME同步",
umeSyncDesc: "UME 告警同步、订阅与清单",
@ -30,6 +31,12 @@ const zh = {
webcrtDesc: "浏览器内交互登录已连通网元(SSH/Telnet)",
topology: "拓扑管理",
topologyDesc: "拖拽编排网元拓扑,支持 LLDP/CDP 发现链路",
users: "用户管理",
usersDesc: "管理员添加、禁用用户并重置密码",
audit: "操作审计",
auditDesc: "查看登录与操作记录",
apiKeys: "API Key",
apiKeysDesc: "为用户生成 MCP/脚本用 Token,可设有效期",
},
},
layout: {
@ -39,6 +46,9 @@ const zh = {
titleCollect: "批量采集",
titleWebcrt: "WebCRT",
titleTopology: "拓扑管理",
titleUsers: "用户管理",
titleAudit: "操作审计",
titleApiKeys: "API Key",
navUme: "UME 对接",
netxApi: "netx api",
oclawBridge: "oclaw WSS",
@ -47,6 +57,78 @@ const zh = {
langZh: "中文",
langEn: "English",
},
auth: {
loginTitle: "登录 NetX",
loginHint: "使用本地账号访问运维平台",
username: "用户名",
password: "密码",
login: "登录",
loggingIn: "登录中…",
loginFailed: "登录失败",
logout: "退出",
usersTitle: "用户管理",
usersHint: "仅管理员可创建与管理本地账号。",
addUser: "添加用户",
role: "角色",
roleAdmin: "管理员",
roleUser: "普通用户",
status: "状态",
active: "启用",
disabled: "禁用",
enable: "启用",
disable: "禁用",
actions: "操作",
newPassword: "新密码",
resetPassword: "重置密码",
userCreated: "用户已创建",
userUpdated: "用户已更新",
auditTitle: "操作审计",
auditHintAdmin: "查看所有用户的登录与操作记录。",
auditHintUser: "查看你自己的操作记录。",
filterUsername: "用户名筛选",
filterAction: "动作筛选",
colTime: "时间",
colUser: "用户",
colAction: "动作",
colMethod: "方法",
colPath: "路径",
colStatus: "状态码",
colIp: "IP",
apiKeysTitle: "API Key 管理",
apiKeysHint: "生成长期 Token 供 MCP/脚本调用;明文仅创建时显示一次。管理员可为其他用户签发。",
tokenName: "名称",
expiresIn: "有效期",
expire7d: "7 天",
expire30d: "30 天",
expire90d: "90 天",
expire365d: "1 年",
expireNever: "永不过期",
tokenOwner: "所属用户",
tokenOwnerSelf: "自己({{user}})",
createToken: "生成 Key",
tokenCreated: "API Key 已生成",
tokenRevoked: "已吊销",
tokenOnceHint: "请立即复制保存,关闭后无法再次查看明文:",
copyToken: "复制",
tokenCopied: "已复制到剪贴板",
tokenCopyFailed: "复制失败",
expiresAt: "到期时间",
lastUsed: "最近使用",
tokenStatusActive: "有效",
tokenStatusExpired: "已过期",
tokenStatusRevoked: "已吊销",
revokeToken: "吊销",
revokeConfirm: "确定吊销该 API Key?",
forceChangeTitle: "请修改初始密码",
forceChangeHint: "账号 {{user}} 仍在使用默认密码,登录前必须先修改。",
oldPassword: "当前密码",
confirmPassword: "确认新密码",
savePassword: "保存新密码",
savingPassword: "保存中…",
passwordTooShort: "新密码至少 6 位",
passwordMismatch: "两次输入的新密码不一致",
passwordMustChange: "新密码不能与默认/旧密码相同",
},
collect: {
create: {
title: "新建采集任务",

View file

@ -2343,3 +2343,79 @@ pre {
min-height: 420px;
}
}
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background:
radial-gradient(ellipse at 20% 20%, rgba(33, 150, 243, 0.18), transparent 55%),
radial-gradient(ellipse at 80% 0%, rgba(21, 101, 192, 0.2), transparent 45%),
#eef2f7;
}
.login-card {
width: min(400px, 100%);
padding: 28px 28px 24px;
background: #fff;
border: 1px solid #d8dee8;
border-radius: 10px;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
display: flex;
flex-direction: column;
gap: 12px;
}
.login-card__brand {
font-size: 22px;
font-weight: 700;
color: #1565c0;
letter-spacing: 0.02em;
}
.login-card__title {
margin: 0;
font-size: 20px;
color: #0f172a;
}
.login-card__hint {
margin: 0;
color: #64748b;
font-size: 13px;
}
.login-card__label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13px;
color: #334155;
}
.login-card__label input {
height: 36px;
padding: 0 10px;
}
.login-card__error {
color: #b91c1c;
font-size: 13px;
}
.login-card__submit {
margin-top: 4px;
height: 38px;
border: 0;
border-radius: 6px;
background: #1565c0;
color: #fff;
font-weight: 600;
}
.login-card__submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}

View file

@ -6,6 +6,7 @@ import { getPageTitleKey, isWorkbenchPath } from "../config/modules";
import { useAppWindowRegistration } from "../hooks/useAppWindowRegistration";
import { useI18n } from "../i18n";
import { returnToWorkbench } from "../utils/workbench";
import { useAuth } from "../auth/AuthContext";
type ConnLevel = "up" | "down" | "unknown";
@ -26,6 +27,7 @@ type Props = {
export function AppLayout({ connections, children }: Props) {
const { t } = useI18n();
const { pathname } = useLocation();
const { user, logout } = useAuth();
const onWorkbench = isWorkbenchPath(pathname);
const pageTitle = t(getPageTitleKey(pathname));
const netxSuffix =
@ -86,6 +88,20 @@ export function AppLayout({ connections, children }: Props) {
{t("layout.oclawBridge")}: {connections.oclawBridge}
{oclawSuffix}
</span>
{user ? (
<span className="conn-pill conn-pill--on-brand conn-pill--up" title={user.role}>
{user.username}
</span>
) : null}
{user ? (
<button
type="button"
className="header-menu__trigger header-menu__trigger--on-brand"
onClick={() => void logout()}
>
{t("auth.logout")}
</button>
) : null}
<HeaderMenu />
</div>
</header>

View file

@ -7,6 +7,7 @@ import App from "./App.tsx";
import { ErrorBoundary } from "./layout/ErrorBoundary";
import { I18nProvider } from "./i18n";
import { ToastProvider } from "./hooks/useToast";
import { AuthProvider } from "./auth/AuthContext";
const queryClient = new QueryClient();
@ -17,7 +18,9 @@ createRoot(document.getElementById("root")!).render(
<I18nProvider>
<ToastProvider>
<BrowserRouter>
<App />
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</ToastProvider>
</I18nProvider>

View file

@ -0,0 +1,199 @@
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";
type TokenRow = {
id: string;
name: string;
user_id: string;
username: string;
created_at: string | null;
expires_at: string | null;
last_used_at: string | null;
revoked: boolean;
expired: boolean;
active: boolean;
};
type UserRow = {
id: string;
username: string;
role: string;
is_active: boolean;
};
const EXPIRY_OPTIONS = [
{ value: 7, labelKey: "auth.expire7d" },
{ value: 30, labelKey: "auth.expire30d" },
{ value: 90, labelKey: "auth.expire90d" },
{ value: 365, labelKey: "auth.expire365d" },
{ value: 0, labelKey: "auth.expireNever" },
] as const;
export function ApiTokensPage() {
const { t } = useI18n();
const { ready, user, isAdmin } = useAuth();
const { showOk, showError } = useToast();
const qc = useQueryClient();
const [name, setName] = useState("mcp");
const [expiresInDays, setExpiresInDays] = useState(90);
const [ownerUserId, setOwnerUserId] = useState("");
const [createdPlain, setCreatedPlain] = useState("");
const tokensQuery = useQuery({
queryKey: ["apiTokens"],
queryFn: () => apiGet<{ items: TokenRow[] }>("/v1/api-tokens"),
enabled: ready,
});
const usersQuery = useQuery({
queryKey: ["appUsers"],
queryFn: () => apiGet<{ items: UserRow[] }>("/v1/users"),
enabled: ready && isAdmin,
});
const createMut = useMutation({
mutationFn: () =>
apiPost<{ token: TokenRow & { token: string } }>("/v1/api-tokens", {
name: name.trim() || "mcp",
expires_in_days: expiresInDays,
user_id: isAdmin && ownerUserId ? ownerUserId : undefined,
}),
onSuccess: async (data) => {
setCreatedPlain(data.token.token);
showOk(t("auth.tokenCreated"));
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"));
await qc.invalidateQueries({ queryKey: ["apiTokens"] });
},
onError: (e) => showError(String(e instanceof Error ? e.message : e)),
});
const items = useMemo(() => tokensQuery.data?.items || [], [tokensQuery.data]);
const users = useMemo(() => usersQuery.data?.items || [], [usersQuery.data]);
const onCreate = (e: FormEvent) => {
e.preventDefault();
setCreatedPlain("");
createMut.mutate();
};
const copyToken = async () => {
try {
await navigator.clipboard.writeText(createdPlain);
showOk(t("auth.tokenCopied"));
} catch {
showError(t("auth.tokenCopyFailed"));
}
};
return (
<div className="panel">
<h2 className="panel__title">{t("auth.apiKeysTitle")}</h2>
<p className="panel__hint">{t("auth.apiKeysHint")}</p>
<form className="form-row" onSubmit={onCreate} style={{ gap: 8, flexWrap: "wrap", marginBottom: 16 }}>
<input
placeholder={t("auth.tokenName")}
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
<select
value={expiresInDays}
onChange={(e) => setExpiresInDays(Number(e.target.value))}
aria-label={t("auth.expiresIn")}
>
{EXPIRY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{t(opt.labelKey)}
</option>
))}
</select>
{isAdmin ? (
<select
value={ownerUserId}
onChange={(e) => setOwnerUserId(e.target.value)}
aria-label={t("auth.tokenOwner")}
>
<option value="">{t("auth.tokenOwnerSelf", { user: user?.username || "" })}</option>
{users
.filter((u) => u.is_active)
.map((u) => (
<option key={u.id} value={u.id}>
{u.username} ({u.role})
</option>
))}
</select>
) : null}
<button type="submit" disabled={createMut.isPending}>
{t("auth.createToken")}
</button>
</form>
{createdPlain ? (
<div className="panel" style={{ marginBottom: 16, background: "#f8fafc" }}>
<div className="panel__hint">{t("auth.tokenOnceHint")}</div>
<code style={{ wordBreak: "break-all", display: "block", margin: "8px 0" }}>{createdPlain}</code>
<button type="button" onClick={() => void copyToken()}>
{t("auth.copyToken")}
</button>
</div>
) : null}
{tokensQuery.isLoading ? <div>{t("common.refreshing")}</div> : null}
<table className="data-table">
<thead>
<tr>
<th>{t("auth.tokenName")}</th>
<th>{t("auth.tokenOwner")}</th>
<th>{t("auth.colTime")}</th>
<th>{t("auth.expiresAt")}</th>
<th>{t("auth.lastUsed")}</th>
<th>{t("auth.status")}</th>
<th>{t("auth.actions")}</th>
</tr>
</thead>
<tbody>
{items.map((row) => (
<tr key={row.id}>
<td>{row.name}</td>
<td>{row.username || row.user_id}</td>
<td>{row.created_at || "-"}</td>
<td>{row.expires_at || t("auth.expireNever")}</td>
<td>{row.last_used_at || "-"}</td>
<td>
{row.revoked
? t("auth.tokenStatusRevoked")
: row.expired
? t("auth.tokenStatusExpired")
: t("auth.tokenStatusActive")}
</td>
<td>
<button
type="button"
disabled={row.revoked || revokeMut.isPending}
onClick={() => {
if (window.confirm(t("auth.revokeConfirm"))) revokeMut.mutate(row.id);
}}
>
{t("auth.revokeToken")}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

119
web/src/pages/AuditPage.tsx Normal file
View file

@ -0,0 +1,119 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "../auth/AuthContext";
import { useI18n } from "../i18n";
import { apiGet } from "../services/api";
type AuditItem = {
id: string;
ts: string | null;
actor_username: string;
action: string;
method: string;
path: string;
status_code: number;
client_ip: string;
detail: Record<string, unknown>;
};
export function AuditPage() {
const { t } = useI18n();
const { ready, isAdmin } = useAuth();
const [page, setPage] = useState(1);
const [username, setUsername] = useState("");
const [action, setAction] = useState("");
const query = useQuery({
queryKey: ["auditLogs", page, username, action],
queryFn: () => {
const p = new URLSearchParams();
p.set("page", String(page));
p.set("page_size", "50");
if (username.trim()) p.set("username", username.trim());
if (action.trim()) p.set("action", action.trim());
return apiGet<{ total: number; page: number; page_size: number; items: AuditItem[] }>(
`/v1/audit-logs?${p.toString()}`,
);
},
enabled: ready,
});
const items = useMemo(() => query.data?.items || [], [query.data]);
const total = query.data?.total || 0;
const pages = Math.max(1, Math.ceil(total / 50));
return (
<div className="panel">
<h2 className="panel__title">{t("auth.auditTitle")}</h2>
<p className="panel__hint">{isAdmin ? t("auth.auditHintAdmin") : t("auth.auditHintUser")}</p>
<div className="form-row" style={{ gap: 8, flexWrap: "wrap", marginBottom: 12 }}>
{isAdmin ? (
<input
placeholder={t("auth.filterUsername")}
value={username}
onChange={(e) => {
setPage(1);
setUsername(e.target.value);
}}
/>
) : null}
<input
placeholder={t("auth.filterAction")}
value={action}
onChange={(e) => {
setPage(1);
setAction(e.target.value);
}}
/>
<button type="button" onClick={() => void query.refetch()}>
{t("common.refresh")}
</button>
</div>
{query.isLoading ? <div>{t("common.refreshing")}</div> : null}
<table className="data-table">
<thead>
<tr>
<th>{t("auth.colTime")}</th>
<th>{t("auth.colUser")}</th>
<th>{t("auth.colAction")}</th>
<th>{t("auth.colMethod")}</th>
<th>{t("auth.colPath")}</th>
<th>{t("auth.colStatus")}</th>
<th>{t("auth.colIp")}</th>
</tr>
</thead>
<tbody>
{items.map((row) => (
<tr key={row.id} title={JSON.stringify(row.detail || {})}>
<td>{row.ts || "-"}</td>
<td>{row.actor_username || "-"}</td>
<td>{row.action}</td>
<td>{row.method}</td>
<td style={{ maxWidth: 280, overflow: "hidden", textOverflow: "ellipsis" }}>{row.path}</td>
<td>{row.status_code}</td>
<td>{row.client_ip || "-"}</td>
</tr>
))}
</tbody>
</table>
<div className="form-row" style={{ gap: 8, marginTop: 12 }}>
<button type="button" disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
{t("common.prevPage")}
</button>
<span>
{t("common.pagerMeta", { total, page, pages })}
</span>
<button
type="button"
disabled={page >= pages}
onClick={() => setPage((p) => Math.min(pages, p + 1))}
>
{t("common.nextPage")}
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,104 @@
import { useState, type FormEvent } from "react";
import { useAuth } from "../auth/AuthContext";
import { useI18n } from "../i18n";
import { apiPost } from "../services/api";
export function ForceChangePasswordPage() {
const { t } = useI18n();
const { user, refreshMe, logout } = useAuth();
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const onSubmit = async (e: FormEvent) => {
e.preventDefault();
setError("");
if (newPassword.length < 6) {
setError(t("auth.passwordTooShort"));
return;
}
if (newPassword !== confirm) {
setError(t("auth.passwordMismatch"));
return;
}
if (newPassword === oldPassword || newPassword === "admin123") {
setError(t("auth.passwordMustChange"));
return;
}
setBusy(true);
try {
await apiPost("/v1/auth/change-password", {
old_password: oldPassword,
new_password: newPassword,
});
await refreshMe();
} catch (err) {
setError(String(err instanceof Error ? err.message : err));
} finally {
setBusy(false);
}
};
return (
<div className="login-page">
<form className="login-card" onSubmit={(e) => void onSubmit(e)}>
<div className="login-card__brand">NetX</div>
<h1 className="login-card__title">{t("auth.forceChangeTitle")}</h1>
<p className="login-card__hint">
{t("auth.forceChangeHint", { user: user?.username || "admin" })}
</p>
<label className="login-card__label">
{t("auth.oldPassword")}
<input
type="password"
autoComplete="current-password"
autoFocus
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
disabled={busy}
required
/>
</label>
<label className="login-card__label">
{t("auth.newPassword")}
<input
type="password"
autoComplete="new-password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={busy}
required
minLength={6}
/>
</label>
<label className="login-card__label">
{t("auth.confirmPassword")}
<input
type="password"
autoComplete="new-password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
disabled={busy}
required
minLength={6}
/>
</label>
{error ? <div className="login-card__error">{error}</div> : null}
<button type="submit" className="login-card__submit" disabled={busy}>
{busy ? t("auth.savingPassword") : t("auth.savePassword")}
</button>
<button
type="button"
className="login-card__submit"
style={{ background: "#64748b" }}
disabled={busy}
onClick={() => void logout()}
>
{t("auth.logout")}
</button>
</form>
</div>
);
}

View file

@ -0,0 +1,66 @@
import { useState, type FormEvent } from "react";
import { Navigate, useSearchParams } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { useI18n } from "../i18n";
export function LoginPage() {
const { t } = useI18n();
const { ready, user, login } = useAuth();
const [params] = useSearchParams();
const [username, setUsername] = useState("admin");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
if (ready && user) {
const next = params.get("next") || "/";
return <Navigate to={next.startsWith("/") ? next : "/"} replace />;
}
const onSubmit = async (e: FormEvent) => {
e.preventDefault();
setError("");
setBusy(true);
try {
await login(username.trim(), password);
} catch (err) {
setError(String(err instanceof Error ? err.message : err) || t("auth.loginFailed"));
} finally {
setBusy(false);
}
};
return (
<div className="login-page">
<form className="login-card" onSubmit={(e) => void onSubmit(e)}>
<div className="login-card__brand">NetX</div>
<h1 className="login-card__title">{t("auth.loginTitle")}</h1>
<p className="login-card__hint">{t("auth.loginHint")}</p>
<label className="login-card__label">
{t("auth.username")}
<input
autoFocus
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={busy || !ready}
/>
</label>
<label className="login-card__label">
{t("auth.password")}
<input
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={busy || !ready}
/>
</label>
{error ? <div className="login-card__error">{error}</div> : null}
<button type="submit" className="login-card__submit" disabled={busy || !ready || !password}>
{busy ? t("auth.loggingIn") : t("auth.login")}
</button>
</form>
</div>
);
}

152
web/src/pages/UsersPage.tsx Normal file
View file

@ -0,0 +1,152 @@
import { useMemo, useState, type FormEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Navigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast";
import { apiGet, apiPatch, apiPost } from "../services/api";
type UserRow = {
id: string;
username: string;
role: string;
is_active: boolean;
created_at?: string | null;
};
export function UsersPage() {
const { t } = useI18n();
const { isAdmin, ready } = useAuth();
const { showOk, showError } = useToast();
const qc = useQueryClient();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState("user");
const [resetPwd, setResetPwd] = useState<Record<string, string>>({});
const usersQuery = useQuery({
queryKey: ["appUsers"],
queryFn: () => apiGet<{ items: UserRow[] }>("/v1/users"),
enabled: ready && isAdmin,
});
const createMut = useMutation({
mutationFn: () => apiPost("/v1/users", { username, password, role }),
onSuccess: async () => {
setUsername("");
setPassword("");
setRole("user");
showOk(t("auth.userCreated"));
await qc.invalidateQueries({ queryKey: ["appUsers"] });
},
onError: (e) => showError(String(e instanceof Error ? e.message : e)),
});
const patchMut = useMutation({
mutationFn: (payload: { id: string; body: Record<string, unknown> }) =>
apiPatch(`/v1/users/${encodeURIComponent(payload.id)}`, payload.body),
onSuccess: async () => {
showOk(t("auth.userUpdated"));
await qc.invalidateQueries({ queryKey: ["appUsers"] });
},
onError: (e) => showError(String(e instanceof Error ? e.message : e)),
});
const items = useMemo(() => usersQuery.data?.items || [], [usersQuery.data]);
if (ready && !isAdmin) return <Navigate to="/" replace />;
const onCreate = (e: FormEvent) => {
e.preventDefault();
createMut.mutate();
};
return (
<div className="panel">
<h2 className="panel__title">{t("auth.usersTitle")}</h2>
<p className="panel__hint">{t("auth.usersHint")}</p>
<form className="form-row" onSubmit={onCreate} style={{ gap: 8, flexWrap: "wrap", marginBottom: 16 }}>
<input
placeholder={t("auth.username")}
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
<input
type="password"
placeholder={t("auth.password")}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
/>
<select value={role} onChange={(e) => setRole(e.target.value)}>
<option value="user">{t("auth.roleUser")}</option>
<option value="admin">{t("auth.roleAdmin")}</option>
</select>
<button type="submit" disabled={createMut.isPending}>
{t("auth.addUser")}
</button>
</form>
{usersQuery.isLoading ? <div>{t("common.refreshing")}</div> : null}
<table className="data-table">
<thead>
<tr>
<th>{t("auth.username")}</th>
<th>{t("auth.role")}</th>
<th>{t("auth.status")}</th>
<th>{t("auth.actions")}</th>
</tr>
</thead>
<tbody>
{items.map((u) => (
<tr key={u.id}>
<td>{u.username}</td>
<td>{u.role === "admin" ? t("auth.roleAdmin") : t("auth.roleUser")}</td>
<td>{u.is_active ? t("auth.active") : t("auth.disabled")}</td>
<td>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", alignItems: "center" }}>
<button
type="button"
onClick={() =>
patchMut.mutate({ id: u.id, body: { is_active: !u.is_active } })
}
>
{u.is_active ? t("auth.disable") : t("auth.enable")}
</button>
<select
value={u.role}
onChange={(e) => patchMut.mutate({ id: u.id, body: { role: e.target.value } })}
>
<option value="user">{t("auth.roleUser")}</option>
<option value="admin">{t("auth.roleAdmin")}</option>
</select>
<input
type="password"
placeholder={t("auth.newPassword")}
value={resetPwd[u.id] || ""}
onChange={(e) => setResetPwd((m) => ({ ...m, [u.id]: e.target.value }))}
style={{ width: 140 }}
/>
<button
type="button"
disabled={!resetPwd[u.id] || resetPwd[u.id].length < 6}
onClick={() => {
const pwd = resetPwd[u.id];
patchMut.mutate({ id: u.id, body: { password: pwd } });
setResetPwd((m) => ({ ...m, [u.id]: "" }));
}}
>
{t("auth.resetPassword")}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View file

@ -2,11 +2,13 @@ import { useI18n } from "../i18n";
import { WorkbenchCardIcon } from "../components/WorkbenchCardIcon";
import { modulesInSection, type WorkbenchSection } from "../config/modules";
import { openOrFocusModule } from "../utils/moduleWindows";
import { useAuth } from "../auth/AuthContext";
const SECTIONS: WorkbenchSection[] = ["monitoring", "operations"];
const SECTIONS: WorkbenchSection[] = ["monitoring", "operations", "system"];
export function WorkbenchPage() {
const { t } = useI18n();
const { isAdmin } = useAuth();
return (
<div className="workbench">
@ -14,7 +16,9 @@ export function WorkbenchPage() {
<section key={section} className="wb-section">
<h2 className="wb-section__title">{t(`workbench.${section}`)}</h2>
<div className="wb-grid">
{modulesInSection(section).map((mod) => (
{modulesInSection(section)
.filter((mod) => !mod.adminOnly || isAdmin)
.map((mod) => (
<button
key={mod.moduleId}
type="button"

View file

@ -25,6 +25,44 @@ import type {
TopologyMapItem,
} from "../types";
const AUTH_TOKEN_KEY = "netx_access_token";
export const getAuthToken = (): string | null => {
try {
return localStorage.getItem(AUTH_TOKEN_KEY);
} catch {
return null;
}
};
export const setAuthToken = (token: string): void => {
localStorage.setItem(AUTH_TOKEN_KEY, String(token || ""));
};
export const clearAuthToken = (): void => {
try {
localStorage.removeItem(AUTH_TOKEN_KEY);
} catch {
// ignore
}
};
const authHeaders = (extra?: Record<string, string>): Record<string, string> => {
const h: Record<string, string> = { accept: "application/json", ...(extra || {}) };
const tok = getAuthToken();
if (tok) h.authorization = `Bearer ${tok}`;
return h;
};
const handleUnauthorized = (path: string): void => {
if (path.startsWith("/v1/auth/login")) return;
clearAuthToken();
if (typeof window !== "undefined" && !window.location.pathname.startsWith("/login")) {
const next = `${window.location.pathname}${window.location.search || ""}`;
window.location.assign(`/login?next=${encodeURIComponent(next)}`);
}
};
const parseApiResponse = async (res: Response): Promise<Record<string, unknown>> => {
const text = await res.text();
if (!text) return {};
@ -37,7 +75,11 @@ const parseApiResponse = async (res: Response): Promise<Record<string, unknown>>
};
export const apiGet = async <T,>(path: string): Promise<T> => {
const res = await fetch(path, { headers: { accept: "application/json" } });
const res = await fetch(path, { headers: authHeaders() });
if (res.status === 401) {
handleUnauthorized(path);
throw new Error("401 unauthorized");
}
if (!res.ok) throw new Error(`${res.status} ${path}`);
return (await res.json()) as T;
};
@ -45,10 +87,14 @@ export const apiGet = async <T,>(path: string): Promise<T> => {
export const apiPost = async <T,>(path: string, body: unknown): Promise<T> => {
const res = await fetch(path, {
method: "POST",
headers: { "content-type": "application/json", accept: "application/json" },
headers: authHeaders({ "content-type": "application/json" }),
body: JSON.stringify(body),
});
const data = await parseApiResponse(res);
if (res.status === 401) {
handleUnauthorized(path);
throw new Error(String(data.detail || "unauthorized"));
}
if (!res.ok) throw new Error(String(data.detail || `${res.status} ${path}`));
return data as T;
};
@ -56,17 +102,25 @@ export const apiPost = async <T,>(path: string, body: unknown): Promise<T> => {
export const apiPatch = async <T,>(path: string, body: unknown): Promise<T> => {
const res = await fetch(path, {
method: "PATCH",
headers: { "content-type": "application/json", accept: "application/json" },
headers: authHeaders({ "content-type": "application/json" }),
body: JSON.stringify(body),
});
const data = await parseApiResponse(res);
if (res.status === 401) {
handleUnauthorized(path);
throw new Error(String(data.detail || "unauthorized"));
}
if (!res.ok) throw new Error(String(data.detail || `${res.status} ${path}`));
return data as T;
};
export const apiDelete = async <T,>(path: string): Promise<T> => {
const res = await fetch(path, { method: "DELETE", headers: { accept: "application/json" } });
const res = await fetch(path, { method: "DELETE", headers: authHeaders() });
const data = await parseApiResponse(res);
if (res.status === 401) {
handleUnauthorized(path);
throw new Error(String(data.detail || "unauthorized"));
}
if (!res.ok) throw new Error(String(data.detail || `${res.status} ${path}`));
return data as T;
};
@ -74,10 +128,14 @@ export const apiDelete = async <T,>(path: string): Promise<T> => {
export const apiPut = async <T,>(path: string, body: unknown): Promise<T> => {
const res = await fetch(path, {
method: "PUT",
headers: { "content-type": "application/json", accept: "application/json" },
headers: authHeaders({ "content-type": "application/json" }),
body: JSON.stringify(body),
});
const data = await parseApiResponse(res);
if (res.status === 401) {
handleUnauthorized(path);
throw new Error(String(data.detail || "unauthorized"));
}
if (!res.ok) throw new Error(String(data.detail || `${res.status} ${path}`));
return data as T;
};
@ -369,18 +427,20 @@ export const closeWebcrtSession = (sessionId: string) =>
export const webcrtWsUrl = (sessionId: string): string => {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const path = `/v1/webcrt/sessions/${encodeURIComponent(sessionId)}/ws`;
const tok = getAuthToken();
const qs = tok ? `?access_token=${encodeURIComponent(tok)}` : "";
// Optional override, e.g. ws://127.0.0.1:8890
const override = String((import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_NETX_WS_BASE || "").trim();
if (override) {
return `${override.replace(/\/$/, "")}${path}`;
return `${override.replace(/\/$/, "")}${path}${qs}`;
}
// Vite/preview: HTTP is proxied, but WS proxy is often flaky — hit API directly.
const port = window.location.port;
if (port === "5173" || port === "4173") {
const apiHost = window.location.hostname === "localhost" ? "127.0.0.1" : window.location.hostname;
return `${proto}//${apiHost}:8890${path}`;
return `${proto}//${apiHost}:8890${path}${qs}`;
}
return `${proto}//${window.location.host}${path}`;
return `${proto}//${window.location.host}${path}${qs}`;
};
export const fetchCliMeta = () => apiGet<CliMeta>("/v1/cli/meta");