mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 23:20:51 +08:00
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:
parent
14f14d34bd
commit
6d4cd741ef
35 changed files with 2699 additions and 21 deletions
199
web/src/pages/ApiTokensPage.tsx
Normal file
199
web/src/pages/ApiTokensPage.tsx
Normal 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
119
web/src/pages/AuditPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
web/src/pages/ForceChangePasswordPage.tsx
Normal file
104
web/src/pages/ForceChangePasswordPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
web/src/pages/LoginPage.tsx
Normal file
66
web/src/pages/LoginPage.tsx
Normal 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
152
web/src/pages/UsersPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue