mirror of
https://github.com/hansjone/netx.git
synced 2026-10-15 15:53:20 +08:00
Unify NetX UI to compact ops chrome and add API key purge.
Align network/list shells and audit header with industrial panels; allow permanently deleting revoked API keys. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
4d60601050
commit
046029aa16
11 changed files with 417 additions and 286 deletions
|
|
@ -173,6 +173,16 @@ export function ApiTokensPage() {
|
|||
onError: (e) => showError(String(e instanceof Error ? e.message : e)),
|
||||
});
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id: string) => apiDelete(`/v1/api-tokens/${encodeURIComponent(id)}/purge`),
|
||||
onSuccess: async () => {
|
||||
showOk(t("auth.tokenDeleted"));
|
||||
setEditingRow(null);
|
||||
await qc.invalidateQueries({ queryKey: ["apiTokens"] });
|
||||
},
|
||||
onError: (e) => showError(String(e instanceof Error ? e.message : e)),
|
||||
});
|
||||
|
||||
const onCreate = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setCreatedPlain("");
|
||||
|
|
@ -367,6 +377,16 @@ export function ApiTokensPage() {
|
|||
>
|
||||
{t("auth.revokeToken")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
isDisabled={deleteMut.isPending}
|
||||
onPress={() => {
|
||||
if (window.confirm(t("auth.deleteConfirm"))) deleteMut.mutate(row.id);
|
||||
}}
|
||||
>
|
||||
{t("auth.deleteToken")}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -78,84 +78,89 @@ export function SessionsPage() {
|
|||
const items = sessionsQuery.data?.items || [];
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="page-header">
|
||||
<h1>{t("auth.sessionsTitle")}</h1>
|
||||
<p className="panel__hint">{t("auth.sessionsHint")}</p>
|
||||
</header>
|
||||
|
||||
<div className="panel">
|
||||
<div className="filter-inline" style={{ marginBottom: 12 }}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={revokeOthersMut.isPending || items.filter((s) => !s.current).length === 0}
|
||||
onPress={() => revokeOthersMut.mutate()}
|
||||
>
|
||||
{t("auth.revokeOtherSessions")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onPress={() => void sessionsQuery.refetch()}
|
||||
isDisabled={sessionsQuery.isFetching}
|
||||
>
|
||||
{t("common.refresh")}
|
||||
</Button>
|
||||
<div className="page-stack system-page">
|
||||
<section className="panel">
|
||||
<div className="panel__toolbar">
|
||||
<h2>{t("auth.sessionsTitle")}</h2>
|
||||
</div>
|
||||
<p className="panel__hint">{t("auth.sessionsHint")}</p>
|
||||
<div className="pt-list">
|
||||
<div className="filter-inline">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={revokeOthersMut.isPending || items.filter((s) => !s.current).length === 0}
|
||||
onPress={() => revokeOthersMut.mutate()}
|
||||
>
|
||||
{t("auth.revokeOtherSessions")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onPress={() => void sessionsQuery.refetch()}
|
||||
isDisabled={sessionsQuery.isFetching}
|
||||
>
|
||||
{t("common.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{sessionsQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
{sessionsQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
|
||||
{!items.length && !sessionsQuery.isLoading ? (
|
||||
<p className="muted">{t("auth.sessionsEmpty")}</p>
|
||||
) : (
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("auth.colSession")}</th>
|
||||
<th>{t("auth.colIp")}</th>
|
||||
<th>{t("auth.colClient")}</th>
|
||||
<th>{t("auth.colLastSeen")}</th>
|
||||
<th>{t("auth.colCreated")}</th>
|
||||
<th>{t("auth.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>
|
||||
<code title={row.id}>{row.id.slice(0, 10)}…</code>
|
||||
{row.current ? (
|
||||
<span className="pt-list-status pt-list-status--ok" style={{ marginLeft: 8 }}>
|
||||
{t("auth.sessionCurrent")}
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td>
|
||||
<code>{row.client_ip || "—"}</code>
|
||||
</td>
|
||||
<td title={row.user_agent || undefined}>{summarizeUa(row.user_agent)}</td>
|
||||
<td>{row.last_seen_at ? formatSystemTime(row.last_seen_at) : "—"}</td>
|
||||
<td>{row.created_at ? formatSystemTime(row.created_at) : "—"}</td>
|
||||
<td>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={revokeMut.isPending}
|
||||
onPress={() => {
|
||||
if (row.current && !window.confirm(t("auth.revokeCurrentConfirm"))) return;
|
||||
revokeMut.mutate(row.id);
|
||||
}}
|
||||
>
|
||||
{t("auth.revokeSession")}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
{!items.length && !sessionsQuery.isLoading ? (
|
||||
<div className="pt-list-empty">
|
||||
<p className="muted">{t("auth.sessionsEmpty")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pt-list-table-wrap">
|
||||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("auth.colSession")}</th>
|
||||
<th>{t("auth.colIp")}</th>
|
||||
<th>{t("auth.colClient")}</th>
|
||||
<th>{t("auth.colLastSeen")}</th>
|
||||
<th>{t("auth.colCreated")}</th>
|
||||
<th>{t("auth.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>
|
||||
<code title={row.id}>{row.id.slice(0, 10)}…</code>
|
||||
{row.current ? (
|
||||
<span className="pt-list-status pt-list-status--ok" style={{ marginLeft: 8 }}>
|
||||
{t("auth.sessionCurrent")}
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td>
|
||||
<code>{row.client_ip || "—"}</code>
|
||||
</td>
|
||||
<td title={row.user_agent || undefined}>{summarizeUa(row.user_agent)}</td>
|
||||
<td>{row.last_seen_at ? formatSystemTime(row.last_seen_at) : "—"}</td>
|
||||
<td>{row.created_at ? formatSystemTime(row.created_at) : "—"}</td>
|
||||
<td>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={revokeMut.isPending}
|
||||
onPress={() => {
|
||||
if (row.current && !window.confirm(t("auth.revokeCurrentConfirm"))) return;
|
||||
revokeMut.mutate(row.id);
|
||||
}}
|
||||
>
|
||||
{t("auth.revokeSession")}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ export function AuditLayout() {
|
|||
return (
|
||||
<div className="network-shell audit-shell">
|
||||
<aside className="network-nav" aria-label={t("audit.title")}>
|
||||
<div className="network-nav__brand">{t("audit.title")}</div>
|
||||
<nav className="network-nav__scroll">
|
||||
<ul className="network-nav__list network-nav__list--flat">
|
||||
{AUDIT_NAV.map((item) => (
|
||||
|
|
@ -16,6 +15,7 @@ export function AuditLayout() {
|
|||
<NavLink
|
||||
to={item.path}
|
||||
className={({ isActive }) => `network-nav__link${isActive ? " is-active" : ""}`}
|
||||
end={item.path === "/audit/tasks"}
|
||||
>
|
||||
{t(item.labelKey)}
|
||||
</NavLink>
|
||||
|
|
|
|||
|
|
@ -55,7 +55,6 @@ export function NetworkLayout() {
|
|||
return (
|
||||
<div className="network-shell">
|
||||
<aside className="network-nav" aria-label={t("network.title")}>
|
||||
<div className="network-nav__brand">{t("network.title")}</div>
|
||||
<nav className="network-nav__scroll">
|
||||
<Accordion
|
||||
className="network-nav__accordion"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue