mirror of
https://github.com/Ed1s0nZ/PrivHunterAI.git
synced 2026-10-24 16:44:26 +00:00
refactor: rebuild PrivHunter with React, Gin and SQLite
This commit is contained in:
1 parent
2c842cb1da
commit
bea3940977
96 files changed
+9150
-2309
No files matched your search
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PrivHunter · 安全工作台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1946
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "privhunter-console",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "tsc -b && vite build",
|
||||
"format": "prettier --write src tests *.ts *.json index.html",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@types/react": "^19.1.13",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react": "^5.0.3",
|
||||
"prettier": "^3.9.9",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
export default defineConfig({
|
||||
testDir: "tests",
|
||||
workers: 1,
|
||||
use: { baseURL: "http://127.0.0.1:18222" },
|
||||
webServer: {
|
||||
command: `go run ../cmd/privhunter -listen 127.0.0.1:18222 -proxy 127.0.0.1:19080 -database ../data/e2e-${Date.now()}.db -frontend dist`,
|
||||
url: "http://127.0.0.1:18222/api/auth/status",
|
||||
reuseExistingServer: false,
|
||||
timeout: 120000,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import PasswordDialog from "./components/PasswordDialog";
|
||||
import Overview, { type ResultSelection } from "./pages/Overview";
|
||||
import Findings from "./pages/Findings";
|
||||
import Users from "./pages/Users";
|
||||
import Audit from "./pages/Audit";
|
||||
import Settings from "./pages/Settings";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, send, setCSRF, type Session } from "./api/client";
|
||||
import Login from "./pages/Login";
|
||||
import Layout, { type Page } from "./components/Layout";
|
||||
import useWorkspaceNavigation, {
|
||||
canLeaveWorkspace,
|
||||
} from "./hooks/useWorkspaceNavigation";
|
||||
export default function App() {
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [setup, setSetup] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const {
|
||||
page,
|
||||
navigate: navigatePage,
|
||||
reset,
|
||||
} = useWorkspaceNavigation(session?.user.role);
|
||||
const [selection, setSelection] = useState<ResultSelection>({});
|
||||
const navigate = (next: Page, filter: ResultSelection = {}) => {
|
||||
if (!navigatePage(next)) return;
|
||||
setSelection(filter);
|
||||
};
|
||||
const [passwordOpen, setPasswordOpen] = useState(false);
|
||||
const accept = (s: Session, restore = false) => {
|
||||
setCSRF(s.csrf);
|
||||
setSession(s);
|
||||
if (!restore) reset();
|
||||
};
|
||||
useEffect(() => {
|
||||
const expire = () => {
|
||||
setSession(null);
|
||||
setCSRF("");
|
||||
};
|
||||
window.addEventListener("session-expired", expire);
|
||||
(async () => {
|
||||
try {
|
||||
const v = await api<{ setupRequired: boolean }>("/auth/status");
|
||||
setSetup(v.setupRequired);
|
||||
if (!v.setupRequired) {
|
||||
try {
|
||||
accept(await api<Session>("/auth/me"), true);
|
||||
} catch {}
|
||||
}
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => window.removeEventListener("session-expired", expire);
|
||||
}, []);
|
||||
if (loading) return <div className="loading">正在连接工作台…</div>;
|
||||
if (error)
|
||||
return (
|
||||
<div className="loading">
|
||||
<p role="alert">{error}</p>
|
||||
<button onClick={() => location.reload()}>重试</button>
|
||||
</div>
|
||||
);
|
||||
if (!session)
|
||||
return (
|
||||
<Login setup={setup} onSetup={() => setSetup(false)} onLogin={accept} />
|
||||
);
|
||||
return (
|
||||
<Layout
|
||||
user={session.user}
|
||||
page={page}
|
||||
setPage={navigate}
|
||||
password={() => setPasswordOpen(true)}
|
||||
logout={async () => {
|
||||
if (!canLeaveWorkspace()) return;
|
||||
try {
|
||||
await api("/auth/logout", send("POST", {}));
|
||||
setSession(null);
|
||||
setCSRF("");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<section className="content">
|
||||
{page === "overview" ? (
|
||||
<Overview navigate={navigate} />
|
||||
) : page === "findings" ? (
|
||||
<Findings
|
||||
key={JSON.stringify(selection)}
|
||||
initial={selection}
|
||||
admin={session.user.role === "admin"}
|
||||
/>
|
||||
) : page === "users" ? (
|
||||
<Users currentUser={session.user} />
|
||||
) : page === "audit" ? (
|
||||
<Audit />
|
||||
) : (
|
||||
<Settings key={page} scanner={page === "scanner"} />
|
||||
)}
|
||||
</section>
|
||||
{passwordOpen && (
|
||||
<PasswordDialog
|
||||
close={() => setPasswordOpen(false)}
|
||||
changed={() => {
|
||||
setPasswordOpen(false);
|
||||
setSession(null);
|
||||
setCSRF("");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
export type User = {
|
||||
id: number;
|
||||
username: string;
|
||||
role: "admin" | "viewer";
|
||||
disabled: boolean;
|
||||
};
|
||||
export type Session = { user: User; csrf: string };
|
||||
export type Finding = {
|
||||
id: number;
|
||||
method: string;
|
||||
url: string;
|
||||
requestA: string;
|
||||
requestB: string;
|
||||
responseA: string;
|
||||
responseB: string;
|
||||
result: string;
|
||||
reason: string;
|
||||
confidence: string;
|
||||
review: string;
|
||||
note: string;
|
||||
createdAt: string;
|
||||
};
|
||||
export type FindingSummary = Omit<
|
||||
Finding,
|
||||
"requestA" | "requestB" | "responseA" | "responseB"
|
||||
>;
|
||||
let csrf = "";
|
||||
export function setCSRF(value: string) {
|
||||
csrf = value;
|
||||
}
|
||||
export async function api<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const response = await fetch("/api" + path, {
|
||||
...options,
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": csrf,
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) {
|
||||
if (response.status === 401)
|
||||
window.dispatchEvent(new Event("session-expired"));
|
||||
throw new Error(body.error || "请求失败");
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
export const send = (method: string, value: unknown): RequestInit => ({
|
||||
method,
|
||||
body: JSON.stringify(value),
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Finding } from "../api/client";
|
||||
function fields(value: unknown, prefix = "", out: Record<string, string> = {}) {
|
||||
if (Object.keys(out).length >= 500) return out;
|
||||
if (value && typeof value === "object") {
|
||||
for (const [key, v] of Object.entries(value)) {
|
||||
fields(v, prefix ? prefix + "." + key : key, out);
|
||||
}
|
||||
} else out[prefix || "$"] = JSON.stringify(value) ?? "null";
|
||||
return out;
|
||||
}
|
||||
export default function EvidenceComparison({ finding }: { finding: Finding }) {
|
||||
const [tab, setTab] = useState<"response" | "request" | "diff">("response");
|
||||
const [message, setMessage] = useState("");
|
||||
const diff = useMemo(() => {
|
||||
try {
|
||||
const a = fields(JSON.parse(finding.responseA));
|
||||
const b = fields(JSON.parse(finding.responseB));
|
||||
return [...new Set([...Object.keys(a), ...Object.keys(b)])]
|
||||
.filter((k) => a[k] !== b[k])
|
||||
.map((k) => ({
|
||||
path: k,
|
||||
a: a[k] ?? "(不存在)",
|
||||
b: b[k] ?? "(不存在)",
|
||||
}))
|
||||
.slice(0, 500);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [finding]);
|
||||
const values =
|
||||
tab === "request"
|
||||
? [finding.requestA || "", finding.requestB || ""]
|
||||
: [finding.responseA || "", finding.responseB || ""];
|
||||
return (
|
||||
<>
|
||||
<div className="evidence-tabs" role="tablist" aria-label="证据视图">
|
||||
{[
|
||||
["response", "响应对比"],
|
||||
["request", "请求对比"],
|
||||
["diff", "JSON 字段差异"],
|
||||
].map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
role="tab"
|
||||
aria-selected={tab === key}
|
||||
onClick={() => setTab(key as typeof tab)}
|
||||
>
|
||||
{label}
|
||||
{key === "diff" && diff !== null ? " · " + diff.length : ""}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{tab === "diff" ? (
|
||||
<div className="field-diff">
|
||||
{diff === null ? (
|
||||
<p>响应不是可解析的 JSON,请使用响应对比查看原文。</p>
|
||||
) : diff.length === 0 ? (
|
||||
<p>
|
||||
未发现可见字段差异。脱敏后的内容一致不代表权限判断已得到证实。
|
||||
</p>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>字段路径</th>
|
||||
<th>账号 A</th>
|
||||
<th>账号 B</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{diff.map((d) => (
|
||||
<tr key={d.path}>
|
||||
<td>{d.path}</td>
|
||||
<td>
|
||||
<code>{d.a}</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>{d.b}</code>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<small>最多展示 500 个差异字段;脱敏字段不会恢复原值。</small>
|
||||
</div>
|
||||
) : (
|
||||
<div className="comparison">
|
||||
{values.map((body, i) => (
|
||||
<div key={i}>
|
||||
<div className="section-heading">
|
||||
<h3>
|
||||
账号 {i === 0 ? "A" : "B"}{" "}
|
||||
{tab === "request" ? "请求" : "响应"}
|
||||
</h3>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(body);
|
||||
setMessage("已复制脱敏证据");
|
||||
} catch {
|
||||
setMessage("复制失败,请手动选择文本");
|
||||
}
|
||||
}}
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<pre>{body || "无内容"}</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{message && <small role="status">{message}</small>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { User } from "../api/client";
|
||||
export const navigation = [
|
||||
["overview", "概览", "◈"],
|
||||
["findings", "检测结果", "⌕"],
|
||||
["scanner", "扫描控制", "◎"],
|
||||
["settings", "系统设置", "⚙"],
|
||||
["users", "用户管理", "♙"],
|
||||
["audit", "审计日志", "≡"],
|
||||
] as const;
|
||||
export type Page = (typeof navigation)[number][0];
|
||||
export default function Layout({
|
||||
user,
|
||||
page,
|
||||
setPage,
|
||||
logout,
|
||||
password,
|
||||
children,
|
||||
}: {
|
||||
user: User;
|
||||
page: Page;
|
||||
setPage: (p: Page) => void;
|
||||
logout: () => void;
|
||||
password: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="shell">
|
||||
<aside>
|
||||
<div className="brand">
|
||||
<b>PH</b>
|
||||
<div>
|
||||
PrivHunter<span>安全分析工作台</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="nav-label">WORKSPACE</span>
|
||||
<nav>
|
||||
{navigation
|
||||
.filter(
|
||||
([key]) =>
|
||||
user.role === "admin" ||
|
||||
key === "overview" ||
|
||||
key === "findings",
|
||||
)
|
||||
.map(([key, label, icon]) => (
|
||||
<button
|
||||
key={key}
|
||||
className={page === key ? "active" : ""}
|
||||
aria-current={page === key ? "page" : undefined}
|
||||
onClick={() => setPage(key)}
|
||||
>
|
||||
<span>{icon}</span>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="sidebar-footer">
|
||||
<div className="avatar">
|
||||
{user.username.slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
className="account-button"
|
||||
onClick={password}
|
||||
title="修改密码"
|
||||
>
|
||||
{user.username}
|
||||
</button>
|
||||
<small>{user.role === "admin" ? "管理员" : "只读成员"}</small>
|
||||
</div>
|
||||
<button onClick={logout} aria-label="退出登录">
|
||||
↪
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main>
|
||||
<header>
|
||||
<span>工作台 / {navigation.find(([key]) => key === page)?.[1]}</span>
|
||||
<span className="local-badge">● LOCAL WORKSPACE</span>
|
||||
</header>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
export default function Modal({
|
||||
title,
|
||||
onClose,
|
||||
busy = false,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
busy?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const close = useRef(onClose);
|
||||
close.current = onClose;
|
||||
useEffect(() => {
|
||||
const element = dialog.current;
|
||||
if (!element) return;
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
element.showModal();
|
||||
const overflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
element.close();
|
||||
document.body.style.overflow = overflow;
|
||||
if (previous?.isConnected) previous.focus();
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<dialog
|
||||
ref={dialog}
|
||||
className="detail native-modal"
|
||||
aria-label={title}
|
||||
onCancel={(e) => {
|
||||
e.preventDefault();
|
||||
if (!busy) close.current();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import Modal from "./Modal";
|
||||
import { useState } from "react";
|
||||
import { api, send } from "../api/client";
|
||||
export default function PasswordDialog({
|
||||
close,
|
||||
changed,
|
||||
}: {
|
||||
close: () => void;
|
||||
changed: () => void;
|
||||
}) {
|
||||
const [current, setCurrent] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
return (
|
||||
<Modal title="修改密码" onClose={close} busy={busy}>
|
||||
<form
|
||||
className="form-grid"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await api("/auth/password", send("POST", { current, password }));
|
||||
changed();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="page-heading">
|
||||
<h2>修改密码</h2>
|
||||
<button type="button" disabled={busy} onClick={close}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<label>
|
||||
当前密码
|
||||
<input
|
||||
autoFocus
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
新密码
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={12}
|
||||
/>
|
||||
</label>
|
||||
<small>修改后所有现有会话失效,请使用新密码登录。</small>
|
||||
{error && (
|
||||
<p className="error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button className="primary" disabled={busy}>
|
||||
保存新密码
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export const labels: Record<string, string> = {
|
||||
true: "疑似越权",
|
||||
false: "未发现越权",
|
||||
unknown: "待分析",
|
||||
error: "检测失败",
|
||||
pending: "待复核",
|
||||
confirmed: "已确认",
|
||||
false_positive: "误报",
|
||||
resolved: "已解决",
|
||||
};
|
||||
export function Badge({ value }: { value: string }) {
|
||||
return (
|
||||
<span className={"badge badge-" + value}>{labels[value] || value}</span>
|
||||
);
|
||||
}
|
||||
export function QueryState({
|
||||
loading,
|
||||
error,
|
||||
empty = false,
|
||||
retry,
|
||||
}: {
|
||||
loading: boolean;
|
||||
error: string;
|
||||
empty?: boolean;
|
||||
retry: () => void;
|
||||
}) {
|
||||
if (loading) return <div className="empty">正在加载…</div>;
|
||||
if (error)
|
||||
return (
|
||||
<div className="empty">
|
||||
<p className="error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
<button onClick={retry}>重新加载</button>
|
||||
</div>
|
||||
);
|
||||
if (empty)
|
||||
return (
|
||||
<div className="empty">
|
||||
<b>暂无记录</b>
|
||||
<p>符合条件的数据将在这里显示。</p>
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
export function useDebouncedValue<T>(value: T, delay = 250) {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
return debounced;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "../api/client";
|
||||
type State<T> = {
|
||||
path: string | null;
|
||||
data: T | null;
|
||||
error: string;
|
||||
loading: boolean;
|
||||
};
|
||||
export function useQuery<T>(path: string | null) {
|
||||
const [state, setState] = useState<State<T>>({
|
||||
path: null,
|
||||
data: null,
|
||||
error: "",
|
||||
loading: false,
|
||||
});
|
||||
const [version, setVersion] = useState(0);
|
||||
useEffect(() => {
|
||||
if (path === null) {
|
||||
setState({ path: null, data: null, error: "", loading: false });
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
const controller = new AbortController();
|
||||
setState((prev) => ({
|
||||
path,
|
||||
data: prev.path === path ? prev.data : null,
|
||||
error: "",
|
||||
loading: true,
|
||||
}));
|
||||
api<T>(path, { signal: controller.signal })
|
||||
.then((data) => {
|
||||
if (active) setState({ path, data, error: "", loading: false });
|
||||
})
|
||||
.catch((e) => {
|
||||
if (active && e.name !== "AbortError")
|
||||
setState((prev) => ({ ...prev, error: e.message, loading: false }));
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
};
|
||||
}, [path, version]);
|
||||
const reload = useCallback(() => setVersion((v) => v + 1), []);
|
||||
const current =
|
||||
state.path === path
|
||||
? state
|
||||
: { path, data: null, error: "", loading: path !== null };
|
||||
return {
|
||||
data: current.data,
|
||||
error: current.error,
|
||||
loading: current.loading,
|
||||
reload,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useEffect } from "react";
|
||||
export function useUnsavedChanges(dirty: boolean) {
|
||||
useEffect(() => {
|
||||
if (!dirty) return;
|
||||
const unload = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
const navigate = (e: Event) => {
|
||||
if (!window.confirm("有未保存的修改,确定放弃并离开?"))
|
||||
e.preventDefault();
|
||||
};
|
||||
window.addEventListener("beforeunload", unload);
|
||||
window.addEventListener("workspace-before-navigate", navigate);
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", unload);
|
||||
window.removeEventListener("workspace-before-navigate", navigate);
|
||||
};
|
||||
}, [dirty]);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { navigation, type Page } from "../components/Layout";
|
||||
|
||||
export function canLeaveWorkspace() {
|
||||
return window.dispatchEvent(
|
||||
new Event("workspace-before-navigate", { cancelable: true }),
|
||||
);
|
||||
}
|
||||
|
||||
function readPage(): Page {
|
||||
const key = window.location.hash.replace(/^#\/?/, "");
|
||||
return navigation.find(([page]) => page === key)?.[0] ?? "overview";
|
||||
}
|
||||
|
||||
function writePage(page: Page, replace = false) {
|
||||
const url = `${window.location.pathname}${window.location.search}#/${page}`;
|
||||
window.history[replace ? "replaceState" : "pushState"](null, "", url);
|
||||
}
|
||||
|
||||
export default function useWorkspaceNavigation(role?: string) {
|
||||
const [page, setPage] = useState<Page>(readPage);
|
||||
const current = useRef(page);
|
||||
const allowed = useCallback(
|
||||
(next: Page): Page =>
|
||||
role === "viewer" && next !== "overview" && next !== "findings"
|
||||
? "overview"
|
||||
: next,
|
||||
[role],
|
||||
);
|
||||
const move = useCallback((next: Page, replace = false) => {
|
||||
current.current = next;
|
||||
writePage(next, replace);
|
||||
setPage(next);
|
||||
}, []);
|
||||
const navigate = useCallback(
|
||||
(next: Page) => {
|
||||
if (next === current.current) return true;
|
||||
if (!canLeaveWorkspace()) return false;
|
||||
move(allowed(next));
|
||||
return true;
|
||||
},
|
||||
[allowed, move],
|
||||
);
|
||||
useEffect(() => {
|
||||
move(allowed(current.current), true);
|
||||
const onHistory = () => {
|
||||
const next = allowed(readPage());
|
||||
if (next !== current.current && !canLeaveWorkspace()) {
|
||||
writePage(current.current, true);
|
||||
return;
|
||||
}
|
||||
move(next, true);
|
||||
};
|
||||
window.addEventListener("hashchange", onHistory);
|
||||
return () => {
|
||||
window.removeEventListener("hashchange", onHistory);
|
||||
};
|
||||
}, [allowed, move]);
|
||||
useEffect(() => {
|
||||
document.title = `${navigation.find(([key]) => key === page)?.[1]} · PrivHunter`;
|
||||
}, [page]);
|
||||
return { page: allowed(page), navigate, reset: () => move("overview", true) };
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./style.css";
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useDebouncedValue } from "../hooks/useDebouncedValue";
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "../hooks/useQuery";
|
||||
import { QueryState } from "../components/Status";
|
||||
type Entry = {
|
||||
id: number;
|
||||
actor: string;
|
||||
action: string;
|
||||
target: string;
|
||||
createdAt: string;
|
||||
};
|
||||
const actions: Record<string, string> = {
|
||||
setup: "初始化工作台",
|
||||
login: "登录",
|
||||
password_changed: "修改密码",
|
||||
password_reset: "重置密码",
|
||||
create_user: "创建成员",
|
||||
update_user: "调整成员",
|
||||
review: "复核记录",
|
||||
bulk_review: "批量复核",
|
||||
delete_finding: "删除记录",
|
||||
update_settings: "保存配置",
|
||||
export: "导出报告",
|
||||
};
|
||||
export default function Audit() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const [action, setAction] = useState("");
|
||||
const debounced = useDebouncedValue(search);
|
||||
const q = useQuery<{ data: Entry[]; total: number }>(
|
||||
"/audit?" +
|
||||
new URLSearchParams({ page: String(page), search: debounced, action }),
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<span className="eyebrow">ACTIVITY LOG</span>
|
||||
<h1>审计日志</h1>
|
||||
<p>追踪登录、配置修改与证据复核操作。</p>
|
||||
</div>
|
||||
<button onClick={q.reload}>刷新</button>
|
||||
</div>
|
||||
<div className="panel">
|
||||
<div className="toolbar">
|
||||
<input
|
||||
aria-label="搜索审计记录"
|
||||
placeholder="搜索操作者或操作对象…"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
aria-label="审计操作类型"
|
||||
value={action}
|
||||
onChange={(e) => {
|
||||
setAction(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">全部操作</option>
|
||||
{Object.entries(actions).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{(search || action) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearch("");
|
||||
setAction("");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
清除筛选
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<QueryState {...q} empty={q.data?.data.length === 0} retry={q.reload} />
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>操作者</th>
|
||||
<th>操作</th>
|
||||
<th>对象</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{q.data?.data.map((v) => (
|
||||
<tr key={v.id}>
|
||||
<td>{new Date(v.createdAt).toLocaleString()}</td>
|
||||
<td>{v.actor}</td>
|
||||
<td>
|
||||
{actions[v.action] || v.action}
|
||||
<small className="audit-code">{v.action}</small>
|
||||
</td>
|
||||
<td>{v.target}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="pagination">
|
||||
<span>共 {q.data?.total || 0} 条</span>
|
||||
<button disabled={page === 1} onClick={() => setPage((v) => v - 1)}>
|
||||
上一页
|
||||
</button>
|
||||
<span>{page}</span>
|
||||
<button
|
||||
disabled={page * 50 >= (q.data?.total || 0)}
|
||||
onClick={() => setPage((v) => v + 1)}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import Modal from "../components/Modal";
|
||||
import { useDebouncedValue } from "../hooks/useDebouncedValue";
|
||||
import EvidenceComparison from "../components/EvidenceComparison";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, send, type Finding, type FindingSummary } from "../api/client";
|
||||
import { useQuery } from "../hooks/useQuery";
|
||||
import { Badge, QueryState, labels } from "../components/Status";
|
||||
export default function Findings({
|
||||
admin,
|
||||
initial = {},
|
||||
}: {
|
||||
admin: boolean;
|
||||
initial?: { search?: string; result?: string; review?: string };
|
||||
}) {
|
||||
const [search, setSearch] = useState(initial.search || "");
|
||||
const [result, setResult] = useState(initial.result || "");
|
||||
const [review, setReview] = useState(initial.review || "");
|
||||
const [checked, setChecked] = useState<number[]>([]);
|
||||
const [bulkStatus, setBulkStatus] = useState("confirmed");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [selected, setSelected] = useState<FindingSummary | null>(null);
|
||||
const detailQuery = useQuery<Finding>(
|
||||
selected ? "/findings/" + selected.id : null,
|
||||
);
|
||||
const [note, setNote] = useState("");
|
||||
const [status, setStatus] = useState("pending");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const debouncedSearch = useDebouncedValue(search);
|
||||
const q = useQuery<{ data: FindingSummary[]; total: number }>(
|
||||
"/findings?" +
|
||||
new URLSearchParams({
|
||||
search: debouncedSearch,
|
||||
result,
|
||||
review,
|
||||
page: String(page),
|
||||
size: "20",
|
||||
}),
|
||||
);
|
||||
useEffect(() => {
|
||||
setChecked([]);
|
||||
}, [search, result, review, page]);
|
||||
const bulk = async () => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await api(
|
||||
"/findings/bulk-review",
|
||||
send("POST", { ids: checked, review: bulkStatus }),
|
||||
);
|
||||
setNotice(`已更新 ${checked.length} 条记录`);
|
||||
setChecked([]);
|
||||
q.reload();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const mutate = async (method: string, body?: unknown) => {
|
||||
if (!selected) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await api("/findings/" + selected.id, send(method, body || {}));
|
||||
setSelected(null);
|
||||
q.reload();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<span className="eyebrow">EVIDENCE & REVIEW</span>
|
||||
<h1>检测结果</h1>
|
||||
<p>检索权限风险,对比证据并记录结论。</p>
|
||||
</div>
|
||||
<div className="toolbar">
|
||||
<a
|
||||
className="download"
|
||||
href={
|
||||
"/api/export?" +
|
||||
new URLSearchParams({ search, result, review, format: "csv" })
|
||||
}
|
||||
>
|
||||
导出 CSV
|
||||
</a>
|
||||
<a
|
||||
className="download"
|
||||
href={
|
||||
"/api/export?" +
|
||||
new URLSearchParams({ search, result, review, format: "json" })
|
||||
}
|
||||
>
|
||||
导出 JSON
|
||||
</a>
|
||||
<button onClick={q.reload}>刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="panel">
|
||||
<div className="toolbar">
|
||||
<input
|
||||
aria-label="搜索 URL"
|
||||
placeholder="搜索 URL…"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
aria-label="检测判定"
|
||||
value={result}
|
||||
onChange={(e) => {
|
||||
setResult(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">全部判定</option>
|
||||
{["true", "false", "unknown", "error"].map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{labels[v]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
aria-label="复核状态"
|
||||
value={review}
|
||||
onChange={(e) => {
|
||||
setReview(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">全部复核状态</option>
|
||||
{["pending", "confirmed", "false_positive", "resolved"].map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{labels[v]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{notice && (
|
||||
<p className="success" role="status">
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
{error && !selected && (
|
||||
<p className="error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{(search || result || review) && (
|
||||
<button
|
||||
className="filter-reset"
|
||||
onClick={() => {
|
||||
setSearch("");
|
||||
setResult("");
|
||||
setReview("");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
清除筛选条件 ×
|
||||
</button>
|
||||
)}
|
||||
{admin && checked.length > 0 && (
|
||||
<div className="bulk-toolbar">
|
||||
<span>已选 {checked.length} 条(当前页)</span>
|
||||
<select
|
||||
aria-label="批量复核状态"
|
||||
value={bulkStatus}
|
||||
onChange={(e) => setBulkStatus(e.target.value)}
|
||||
>
|
||||
{["pending", "confirmed", "false_positive", "resolved"].map(
|
||||
(v) => (
|
||||
<option key={v} value={v}>
|
||||
{labels[v]}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
<button className="primary" disabled={busy} onClick={bulk}>
|
||||
应用复核状态
|
||||
</button>
|
||||
<button onClick={() => setChecked([])}>取消选择</button>
|
||||
</div>
|
||||
)}
|
||||
<QueryState {...q} empty={q.data?.data.length === 0} retry={q.reload} />
|
||||
{!q.loading && !q.error && !!q.data?.data.length && (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
{admin && (
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="选择当前页全部记录"
|
||||
checked={
|
||||
!!q.data?.data.length &&
|
||||
checked.length === q.data.data.length
|
||||
}
|
||||
onChange={(e) =>
|
||||
setChecked(
|
||||
e.target.checked
|
||||
? q.data!.data.map((v) => v.id)
|
||||
: [],
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}{" "}
|
||||
请求
|
||||
</th>
|
||||
<th>判定</th>
|
||||
<th>复核</th>
|
||||
<th>可信度</th>
|
||||
<th>检测时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{q.data.data.map((v) => (
|
||||
<tr
|
||||
key={v.id}
|
||||
onClick={() => {
|
||||
setSelected(v);
|
||||
setNote(v.note);
|
||||
setStatus(v.review);
|
||||
setError("");
|
||||
}}
|
||||
>
|
||||
<td>
|
||||
{admin && (
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`选择记录 ${v.id}`}
|
||||
checked={checked.includes(v.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) =>
|
||||
setChecked((prev) =>
|
||||
e.target.checked
|
||||
? [...prev, v.id]
|
||||
: prev.filter((id) => id !== v.id),
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
className="text-button"
|
||||
onClick={() => {
|
||||
setSelected(v);
|
||||
setNote(v.note);
|
||||
setStatus(v.review);
|
||||
}}
|
||||
>
|
||||
<span className="method">{v.method}</span> {v.url}
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<Badge value={v.result} />
|
||||
</td>
|
||||
<td>
|
||||
<Badge value={v.review} />
|
||||
</td>
|
||||
<td>{v.confidence || "—"}</td>
|
||||
<td>{new Date(v.createdAt).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<div className="pagination">
|
||||
<span>共 {q.data?.total || 0} 条</span>
|
||||
<button disabled={page === 1} onClick={() => setPage((p) => p - 1)}>
|
||||
上一页
|
||||
</button>
|
||||
<span>{page}</span>
|
||||
<button
|
||||
disabled={page * 20 >= (q.data?.total || 0)}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{selected && (
|
||||
<Modal title="检测证据" busy={busy} onClose={() => setSelected(null)}>
|
||||
<div className="page-heading">
|
||||
<h2>检测证据 #{selected.id}</h2>
|
||||
<button autoFocus disabled={busy} onClick={() => setSelected(null)}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<p className="url">
|
||||
{selected.method} {selected.url}
|
||||
</p>
|
||||
<Badge value={selected.result} />
|
||||
<p>{selected.reason}</p>
|
||||
<QueryState {...detailQuery} retry={detailQuery.reload} />
|
||||
{detailQuery.data && (
|
||||
<EvidenceComparison finding={detailQuery.data} />
|
||||
)}
|
||||
{admin && (
|
||||
<div className="review-form">
|
||||
<label>
|
||||
复核状态
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
{["pending", "confirmed", "false_positive", "resolved"].map(
|
||||
(v) => (
|
||||
<option key={v} value={v}>
|
||||
{labels[v]}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
备注
|
||||
<textarea
|
||||
maxLength={4000}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<button
|
||||
className="primary"
|
||||
disabled={busy}
|
||||
onClick={() => mutate("PATCH", { review: status, note })}
|
||||
>
|
||||
保存复核
|
||||
</button>
|
||||
<button
|
||||
className="danger"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (confirm("删除这条检测记录?")) mutate("DELETE");
|
||||
}}
|
||||
>
|
||||
删除记录
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useState } from "react";
|
||||
import { api, send, type Session } from "../api/client";
|
||||
export default function Login({
|
||||
setup,
|
||||
onLogin,
|
||||
onSetup,
|
||||
}: {
|
||||
setup: boolean;
|
||||
onLogin: (s: Session) => void;
|
||||
onSetup: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
return (
|
||||
<div className="login">
|
||||
<div className="login-surface">
|
||||
<div className="login-story">
|
||||
<div className="login-brand">
|
||||
<b>PH</b>
|
||||
<span>
|
||||
PrivHunter<small>安全分析工作台</small>
|
||||
</span>
|
||||
</div>
|
||||
<span className="eyebrow">SECURITY OPERATIONS</span>
|
||||
<h1>
|
||||
看清权限边界。
|
||||
<br />
|
||||
让证据说话。
|
||||
</h1>
|
||||
<p>
|
||||
从被动流量到权限复核,将每一次检测变成可追踪、可验证的安全记录。
|
||||
</p>
|
||||
<div className="login-capabilities">
|
||||
<span>被动流量检测</span>
|
||||
<span>证据对比复核</span>
|
||||
<span>本地数据存储</span>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
className="login-card"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (setup) {
|
||||
await api(
|
||||
"/auth/setup",
|
||||
send("POST", { username: name, password }),
|
||||
);
|
||||
setPassword("");
|
||||
onSetup();
|
||||
} else
|
||||
onLogin(
|
||||
await api<Session>(
|
||||
"/auth/login",
|
||||
send("POST", { username: name, password }),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="eyebrow">WORKSPACE ACCESS</span>
|
||||
<h2>{setup ? "创建管理员" : "欢迎回来"}</h2>
|
||||
<p>
|
||||
{setup
|
||||
? "首次启动,请设置工作台管理员。"
|
||||
: "登录后访问检测记录与扫描配置。"}
|
||||
</p>
|
||||
<label>
|
||||
用户名
|
||||
<input
|
||||
autoComplete="username"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
minLength={3}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
密码
|
||||
<input
|
||||
type="password"
|
||||
autoComplete={setup ? "new-password" : "current-password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={setup ? 12 : 1}
|
||||
/>
|
||||
</label>
|
||||
{setup && <small>密码至少 12 字节,最长 72 字节。</small>}
|
||||
{error && (
|
||||
<p className="error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button className="primary" disabled={busy}>
|
||||
{busy ? "正在处理…" : setup ? "初始化工作台" : "登录工作台 →"}
|
||||
</button>
|
||||
<small>会话受保护 · 数据保存在本地</small>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import { useEffect } from "react";
|
||||
import { useQuery } from "../hooks/useQuery";
|
||||
import { Badge, QueryState } from "../components/Status";
|
||||
import type { FindingSummary } from "../api/client";
|
||||
import type { Page } from "../components/Layout";
|
||||
export type ResultSelection = {
|
||||
result?: string;
|
||||
review?: string;
|
||||
search?: string;
|
||||
};
|
||||
type Dashboard = {
|
||||
counts: Record<string, number>;
|
||||
summary: {
|
||||
reviews: Record<string, number>;
|
||||
trend: { date: string; total: number; suspected: number }[];
|
||||
targets: { host: string; total: number; pending: number }[];
|
||||
};
|
||||
recent: FindingSummary[];
|
||||
readiness?: {
|
||||
scope: boolean;
|
||||
identity: boolean;
|
||||
model: boolean;
|
||||
paused: boolean;
|
||||
};
|
||||
runtime?: { queued: number; processed: number; dropped: number };
|
||||
};
|
||||
export default function Overview({
|
||||
navigate,
|
||||
}: {
|
||||
navigate: (page: Page, selection?: ResultSelection) => void;
|
||||
}) {
|
||||
const q = useQuery<Dashboard>("/dashboard");
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
if (document.visibilityState === "visible") q.reload();
|
||||
}, 30000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
const data = q.data;
|
||||
const max = Math.max(1, ...(data?.summary.trend.map((v) => v.total) || []));
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<span className="eyebrow">WORKSPACE / OVERVIEW</span>
|
||||
<h1>安全态势概览</h1>
|
||||
<p>先确认采集准备,再处理待复核证据。数据每 30 秒自动更新。</p>
|
||||
</div>
|
||||
<div className="toolbar">
|
||||
<button onClick={q.reload}>刷新数据</button>
|
||||
<button
|
||||
className="primary"
|
||||
onClick={() => navigate("findings", { review: "pending" })}
|
||||
>
|
||||
进入复核队列 →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<QueryState {...q} retry={q.reload} />
|
||||
{data && (
|
||||
<>
|
||||
{data.readiness && (
|
||||
<div className="readiness-strip">
|
||||
<div>
|
||||
<span
|
||||
className={
|
||||
"live-dot " + (!data.readiness.paused ? "running" : "")
|
||||
}
|
||||
/>
|
||||
<b>{data.readiness.paused ? "扫描已暂停" : "扫描已启用"}</b>
|
||||
<small>
|
||||
{data.runtime?.queued || 0} 个排队 · 本次处理{" "}
|
||||
{data.runtime?.processed || 0} 条
|
||||
</small>
|
||||
</div>
|
||||
<button onClick={() => navigate("scanner")}>
|
||||
{data.readiness.scope && data.readiness.identity
|
||||
? "管理采集"
|
||||
: "完成采集配置"}{" "}
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="stat-grid">
|
||||
{[
|
||||
{
|
||||
label: "检测总数",
|
||||
value: data.counts.total,
|
||||
sub: "全部已保存证据",
|
||||
selection: {},
|
||||
},
|
||||
{
|
||||
label: "待人工复核",
|
||||
value: data.summary.reviews.pending,
|
||||
sub: "尚未形成复核结论",
|
||||
selection: { review: "pending" },
|
||||
},
|
||||
{
|
||||
label: "已确认风险",
|
||||
value: data.summary.reviews.confirmed,
|
||||
sub: "人工确认后的记录",
|
||||
selection: { review: "confirmed" },
|
||||
},
|
||||
{
|
||||
label: "检测失败",
|
||||
value: data.counts.error,
|
||||
sub: "查看原因,检查配置",
|
||||
selection: { result: "error" },
|
||||
},
|
||||
].map((item) => (
|
||||
<button
|
||||
className="stat stat-action"
|
||||
key={item.label}
|
||||
onClick={() => navigate("findings", item.selection)}
|
||||
>
|
||||
<span>
|
||||
{item.label}
|
||||
<span>↗</span>
|
||||
</span>
|
||||
<strong>{item.value || 0}</strong>
|
||||
<small>{item.sub}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="dashboard-grid">
|
||||
<section className="panel trend-panel">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>近 7 日检测趋势</h2>
|
||||
<small>按北京时间 · 已保存检测记录</small>
|
||||
</div>
|
||||
<span className="chart-key">
|
||||
<i />
|
||||
检测总数 <i className="suspect" />
|
||||
疑似越权
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="trend-chart"
|
||||
role="img"
|
||||
aria-label={data.summary.trend
|
||||
.map((v) => `${v.date} 检测 ${v.total},疑似 ${v.suspected}`)
|
||||
.join(";")}
|
||||
>
|
||||
{data.summary.trend.map((v) => (
|
||||
<div className="trend-day" key={v.date}>
|
||||
<span className="trend-count">{v.total}</span>
|
||||
<div className="trend-track">
|
||||
<div
|
||||
className="trend-bar"
|
||||
style={{ height: `${(v.total / max) * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="trend-bar suspect"
|
||||
style={{ height: `${(v.suspected / max) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<small>{v.date.slice(5)}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{data.counts.total === 0 && (
|
||||
<p className="chart-note">
|
||||
暂无检测数据。配置范围并连接上游代理后,这里将显示真实趋势。
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
<section className="panel">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>判定与复核进度</h2>
|
||||
<small>疑似结果仍需人工验证</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="distribution">
|
||||
{[
|
||||
["true", "疑似越权"],
|
||||
["false", "权限拒绝 / 未发现"],
|
||||
["unknown", "待分析"],
|
||||
["error", "失败"],
|
||||
].map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => navigate("findings", { result: key })}
|
||||
>
|
||||
<span>
|
||||
<i className={"result-dot dot-" + key} />
|
||||
{label}
|
||||
</span>
|
||||
<b>{data.counts[key] || 0}</b>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="review-progress">
|
||||
<span>
|
||||
已完成复核{" "}
|
||||
<b>
|
||||
{data.counts.total
|
||||
? (
|
||||
(100 *
|
||||
(data.counts.total -
|
||||
data.summary.reviews.pending)) /
|
||||
data.counts.total
|
||||
).toFixed(0)
|
||||
: 0}
|
||||
%
|
||||
</b>
|
||||
</span>
|
||||
<progress
|
||||
max={Math.max(1, data.counts.total)}
|
||||
value={data.counts.total - data.summary.reviews.pending}
|
||||
/>
|
||||
<small>
|
||||
已解决 {data.summary.reviews.resolved} · 误报{" "}
|
||||
{data.summary.reviews.false_positive}
|
||||
</small>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div className="dashboard-grid">
|
||||
<section className="panel">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>最近检测</h2>
|
||||
<small>按检测时间排序</small>
|
||||
</div>
|
||||
<button
|
||||
className="text-button"
|
||||
onClick={() => navigate("findings")}
|
||||
>
|
||||
查看全部 →
|
||||
</button>
|
||||
</div>
|
||||
{data.recent.length ? (
|
||||
<div className="recent-list">
|
||||
{data.recent.map((v) => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => navigate("findings", { search: v.url })}
|
||||
>
|
||||
<span className="method">{v.method}</span>
|
||||
<span className="recent-url">
|
||||
{v.url}
|
||||
<small>{new Date(v.createdAt).toLocaleString()}</small>
|
||||
</span>
|
||||
<Badge value={v.result} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="action-empty">
|
||||
<span>⌕</span>
|
||||
<h3>第一条证据,从一次采集开始</h3>
|
||||
<p>
|
||||
{data.readiness
|
||||
? "完成目标范围和账号 B 身份配置,再通过代理访问测试接口。"
|
||||
: "工作台尚无检测记录,管理员完成采集后即可在这里查看证据。"}
|
||||
</p>
|
||||
<button
|
||||
onClick={() =>
|
||||
navigate(data.readiness ? "scanner" : "findings")
|
||||
}
|
||||
>
|
||||
{data.readiness ? "设置采集范围" : "查看检测结果"} →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<section className="panel">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>目标复核热点</h2>
|
||||
<small>按待复核数量排序 · 前 5 个域名</small>
|
||||
</div>
|
||||
</div>
|
||||
{data.summary.targets.length ? (
|
||||
<div className="target-list">
|
||||
{data.summary.targets.map((v) => (
|
||||
<button
|
||||
key={v.host}
|
||||
onClick={() =>
|
||||
navigate("findings", {
|
||||
search: v.host,
|
||||
review: "pending",
|
||||
})
|
||||
}
|
||||
>
|
||||
<span>
|
||||
{v.host}
|
||||
<small>累计 {v.total} 条证据</small>
|
||||
</span>
|
||||
<b>
|
||||
{v.pending}
|
||||
<small>待复核</small>
|
||||
</b>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="action-empty compact">
|
||||
<span>◎</span>
|
||||
<h3>尚未采集目标</h3>
|
||||
<p>这里将帮助你识别积压最多的目标。</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
{data.readiness && (
|
||||
<section className="panel">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>采集就绪检查</h2>
|
||||
<small>按顺序完成配置,模型分析为可选步骤。</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="setup-grid">
|
||||
{[
|
||||
{
|
||||
title: "目标范围",
|
||||
ready: data.readiness.scope,
|
||||
text: "明确允许检测的域名或 IP",
|
||||
page: "scanner",
|
||||
},
|
||||
{
|
||||
title: "账号 B 身份",
|
||||
ready: data.readiness.identity,
|
||||
text: "设置用于权限对比的请求头",
|
||||
page: "scanner",
|
||||
},
|
||||
{
|
||||
title: "分析模型",
|
||||
ready: data.readiness.model,
|
||||
text: "未配置时保留证据供人工复核",
|
||||
page: "settings",
|
||||
},
|
||||
].map((v, i) => (
|
||||
<button
|
||||
key={v.title}
|
||||
onClick={() => navigate(v.page as Page)}
|
||||
>
|
||||
<span className={"step-marker " + (v.ready ? "done" : "")}>
|
||||
{v.ready ? "✓" : i + 1}
|
||||
</span>
|
||||
<div>
|
||||
<b>{v.title}</b>
|
||||
<small>{v.text}</small>
|
||||
</div>
|
||||
<span>{v.ready ? "已配置" : "去配置"} →</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
import { useUnsavedChanges } from "../hooks/useUnsavedChanges";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, send } from "../api/client";
|
||||
import { useQuery } from "../hooks/useQuery";
|
||||
import { QueryState } from "../components/Status";
|
||||
type Config = {
|
||||
revision: number;
|
||||
paused: boolean;
|
||||
domains: string[];
|
||||
endpoint: string;
|
||||
model: string;
|
||||
skipSuffixes: string[];
|
||||
denyKeywords: string[];
|
||||
};
|
||||
export default function Settings({ scanner = false }: { scanner?: boolean }) {
|
||||
const runtime = useQuery<{
|
||||
queued: number;
|
||||
dropped: number;
|
||||
processed: number;
|
||||
}>("/scanner/status");
|
||||
const q = useQuery<{
|
||||
settings: Config;
|
||||
hasAPIKey: boolean;
|
||||
hasHeaders: boolean;
|
||||
}>("/settings");
|
||||
const [value, setValue] = useState<Config | null>(null);
|
||||
const [domains, setDomains] = useState("");
|
||||
const [suffixes, setSuffixes] = useState("");
|
||||
const [keywords, setKeywords] = useState("");
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [clearKey, setClearKey] = useState(false);
|
||||
const [clearHeaders, setClearHeaders] = useState(false);
|
||||
useUnsavedChanges(dirty);
|
||||
const [key, setKey] = useState("");
|
||||
const [headers, setHeaders] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
useEffect(() => {
|
||||
if (q.data && !dirty) {
|
||||
setValue(q.data.settings);
|
||||
setDomains((q.data.settings.domains || []).join("\n"));
|
||||
setSuffixes((q.data.settings.skipSuffixes || []).join("\n"));
|
||||
setKeywords((q.data.settings.denyKeywords || []).join("\n"));
|
||||
}
|
||||
}, [q.data, dirty]);
|
||||
const update = (v: Partial<Config>) => {
|
||||
setDirty(true);
|
||||
setValue((prev) => (prev ? { ...prev, ...v } : prev));
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<span className="eyebrow">
|
||||
{scanner ? "CAPTURE & SCOPE" : "CONFIGURATION"}
|
||||
</span>
|
||||
<h1>{scanner ? "扫描控制" : "系统设置"}</h1>
|
||||
<p>
|
||||
{scanner
|
||||
? "限定目标范围,管理被动流量检测。"
|
||||
: "配置模型与过滤规则,敏感字段仅在本地保存。"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="toolbar config-reload">
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (dirty && !confirm("重新加载会放弃未保存修改,确定继续?"))
|
||||
return;
|
||||
setDirty(false);
|
||||
setValue(null);
|
||||
setKey("");
|
||||
setHeaders("");
|
||||
setClearKey(false);
|
||||
setClearHeaders(false);
|
||||
setError("");
|
||||
q.reload();
|
||||
}}
|
||||
>
|
||||
重新加载配置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<QueryState {...q} retry={q.reload} />
|
||||
{value && (
|
||||
<form
|
||||
className="configuration-form"
|
||||
onChange={() => setDirty(true)}
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
const lines = (s: string) =>
|
||||
s
|
||||
.split("\n")
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean);
|
||||
const payload: Record<string, unknown> = {
|
||||
...value,
|
||||
domains: lines(domains),
|
||||
skipSuffixes: lines(suffixes),
|
||||
denyKeywords: lines(keywords),
|
||||
};
|
||||
if (key) payload.apiKey = key;
|
||||
if (headers) {
|
||||
const parsed = JSON.parse(headers);
|
||||
if (
|
||||
!parsed ||
|
||||
Array.isArray(parsed) ||
|
||||
typeof parsed !== "object" ||
|
||||
Object.values(parsed).some((v) => typeof v !== "string")
|
||||
)
|
||||
throw new Error("请求头必须是名称到文本值的 JSON 对象");
|
||||
payload.headers = parsed;
|
||||
}
|
||||
if (clearKey) payload.apiKey = "";
|
||||
if (clearHeaders) payload.headers = {};
|
||||
await api("/settings", send("PUT", payload));
|
||||
setDirty(false);
|
||||
setClearKey(false);
|
||||
setClearHeaders(false);
|
||||
setKey("");
|
||||
setHeaders("");
|
||||
setMessage("设置已保存");
|
||||
q.reload();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="configuration-main">
|
||||
{scanner ? (
|
||||
<>
|
||||
<section className="config-card">
|
||||
<div className="config-card-heading">
|
||||
<span className="config-icon">◎</span>
|
||||
<div>
|
||||
<h2>采集运行状态</h2>
|
||||
<p>控制被动检测,暂停后代理仍可转发流量。</p>
|
||||
</div>
|
||||
<span
|
||||
className={
|
||||
"badge " + (value.paused ? "" : "badge-resolved")
|
||||
}
|
||||
>
|
||||
{value.paused ? "已暂停" : "启用中"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="runtime-metrics">
|
||||
{[
|
||||
["本次已处理", runtime.data?.processed || 0],
|
||||
["等待处理", runtime.data?.queued || 0],
|
||||
["队列满丢弃", runtime.data?.dropped || 0],
|
||||
].map(([label, n]) => (
|
||||
<div key={label}>
|
||||
<span>{label}</span>
|
||||
<b>{n}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="config-row">
|
||||
<label className="switch-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!value.paused}
|
||||
onChange={(e) => update({ paused: !e.target.checked })}
|
||||
/>
|
||||
<span>启用扫描</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="subtle-button"
|
||||
onClick={runtime.reload}
|
||||
>
|
||||
刷新运行状态
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<section className="config-card">
|
||||
<div className="config-card-heading">
|
||||
<span className="config-icon">⌖</span>
|
||||
<div>
|
||||
<h2>目标范围</h2>
|
||||
<p>只有范围内的流量会进入检测队列。</p>
|
||||
</div>
|
||||
<span className="count-pill">
|
||||
{domains.split("\n").filter((v) => v.trim()).length}{" "}
|
||||
个目标
|
||||
</span>
|
||||
</div>
|
||||
<label className="config-field">
|
||||
目标域名(每行一个精确域名或 IP)
|
||||
<textarea
|
||||
className="code-input"
|
||||
rows={4}
|
||||
value={domains}
|
||||
onChange={(e) => setDomains(e.target.value)}
|
||||
placeholder={"api.example.com\n10.0.0.10"}
|
||||
/>
|
||||
<small>输入主机名,不包含协议、路径或通配符。</small>
|
||||
</label>
|
||||
</section>
|
||||
<section className="config-card">
|
||||
<div className="config-card-heading">
|
||||
<span className="config-icon">♙</span>
|
||||
<div>
|
||||
<h2>测试身份 · 账号 B</h2>
|
||||
<p>重放请求时替换凭证,与账号 A 的响应进行对比。</p>
|
||||
</div>
|
||||
<span
|
||||
className={
|
||||
"badge " + (q.data?.hasHeaders ? "badge-resolved" : "")
|
||||
}
|
||||
>
|
||||
{q.data?.hasHeaders ? "已配置" : "待配置"}
|
||||
</span>
|
||||
</div>
|
||||
<label className="config-field">
|
||||
账号 B 请求头(JSON)
|
||||
<textarea
|
||||
className="code-input"
|
||||
rows={4}
|
||||
spellCheck={false}
|
||||
value={headers}
|
||||
onChange={(e) => setHeaders(e.target.value)}
|
||||
placeholder={
|
||||
q.data?.hasHeaders
|
||||
? "已有凭证已隐藏;留空保留当前值"
|
||||
: '{\n "Authorization": "Bearer …"\n}'
|
||||
}
|
||||
/>
|
||||
<small>
|
||||
凭证不会以明文回显。留空保留当前值,输入新内容则整体替换。
|
||||
</small>
|
||||
</label>
|
||||
<details className="advanced-options">
|
||||
<summary>凭证管理</summary>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={clearHeaders}
|
||||
onChange={(e) => setClearHeaders(e.target.checked)}
|
||||
/>
|
||||
保存时清除已有账号 B 请求头
|
||||
</label>
|
||||
</details>
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<section className="config-card">
|
||||
<div className="config-card-heading">
|
||||
<span className="config-icon">✧</span>
|
||||
<div>
|
||||
<h2>模型连接</h2>
|
||||
<p>使用兼容 Chat Completions 的模型辅助分析。</p>
|
||||
</div>
|
||||
<span
|
||||
className={
|
||||
"badge " + (q.data?.hasAPIKey ? "badge-resolved" : "")
|
||||
}
|
||||
>
|
||||
{q.data?.hasAPIKey ? "密钥已配置" : "密钥待配置"}
|
||||
</span>
|
||||
</div>
|
||||
<label className="config-field">
|
||||
模型端点
|
||||
<input
|
||||
type="url"
|
||||
value={value.endpoint}
|
||||
onChange={(e) => update({ endpoint: e.target.value })}
|
||||
placeholder="https://provider.example/v1/chat/completions"
|
||||
/>
|
||||
<small>填写完整接口地址,而非供应商首页。</small>
|
||||
</label>
|
||||
<div className="config-field-grid">
|
||||
<label className="config-field">
|
||||
模型名称
|
||||
<input
|
||||
value={value.model}
|
||||
onChange={(e) => update({ model: e.target.value })}
|
||||
placeholder="输入实际模型 ID"
|
||||
/>
|
||||
</label>
|
||||
<label className="config-field">
|
||||
API Key
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
placeholder={
|
||||
q.data?.hasAPIKey
|
||||
? "留空保留当前密钥"
|
||||
: "输入供应商密钥"
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<details className="advanced-options">
|
||||
<summary>密钥管理</summary>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={clearKey}
|
||||
onChange={(e) => setClearKey(e.target.checked)}
|
||||
/>
|
||||
保存时清除已有模型密钥
|
||||
</label>
|
||||
<small>清除后可继续采集证据,通过人工复核完成判断。</small>
|
||||
</details>
|
||||
</section>
|
||||
<section className="config-card">
|
||||
<div className="config-card-heading">
|
||||
<span className="config-icon">⌕</span>
|
||||
<div>
|
||||
<h2>检测过滤规则</h2>
|
||||
<p>减少无关流量,让检测聚焦业务接口。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="config-field-grid">
|
||||
<label className="config-field">
|
||||
过滤后缀(每行一个)
|
||||
<textarea
|
||||
className="code-input"
|
||||
rows={6}
|
||||
value={suffixes}
|
||||
onChange={(e) => setSuffixes(e.target.value)}
|
||||
/>
|
||||
<small>匹配 URL 路径后缀;匹配到的流量不检测。</small>
|
||||
</label>
|
||||
<label className="config-field">
|
||||
权限拒绝关键词(每行一个)
|
||||
<textarea
|
||||
rows={6}
|
||||
value={keywords}
|
||||
onChange={(e) => setKeywords(e.target.value)}
|
||||
placeholder={"权限不足\n无访问权限"}
|
||||
/>
|
||||
<small>命中后保留证据供人工核实,不直接确认安全。</small>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="configuration-rail">
|
||||
<section className="help-card">
|
||||
<span className="eyebrow">
|
||||
{scanner ? "CONNECTION GUIDE" : "ANALYSIS GUIDE"}
|
||||
</span>
|
||||
<h3>{scanner ? "连接你的测试流量" : "让模型辅助判断"}</h3>
|
||||
{scanner ? (
|
||||
<>
|
||||
<ol>
|
||||
<li>
|
||||
<b>配置范围与身份</b>
|
||||
<p>限定目标,并提供账号 B 的测试凭证。</p>
|
||||
</li>
|
||||
<li>
|
||||
<b>连接上游代理</b>
|
||||
<p>在 Burp 或浏览器代理设置中填写下方地址。</p>
|
||||
<code>127.0.0.1:9080</code>
|
||||
</li>
|
||||
<li>
|
||||
<b>保存并启用</b>
|
||||
<p>访问测试接口,在检测结果中复核证据。</p>
|
||||
</li>
|
||||
</ol>
|
||||
<div className="help-note">
|
||||
HTTPS 流量采集需要信任本机代理证书。
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
模型收到脱敏后的对比证据。模型结论作为线索,需结合业务归属进行人工复核。
|
||||
</p>
|
||||
<div className="help-note">
|
||||
未配置模型时仍可采集和对比证据。
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
<section className="help-card neutral">
|
||||
<h3>配置保存规则</h3>
|
||||
<p>保存后生效;未保存的更改会在离开页面时提醒。</p>
|
||||
<div className="help-rule">
|
||||
<span>凭证回显</span>
|
||||
<b>已隐藏</b>
|
||||
</div>
|
||||
<div className="help-rule">
|
||||
<span>并发保护</span>
|
||||
<b>版本校验</b>
|
||||
</div>
|
||||
<small>配置版本 {value.revision}</small>
|
||||
</section>
|
||||
</div>
|
||||
<div className="configuration-feedback">
|
||||
{error && (
|
||||
<p className="error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{message && (
|
||||
<p className="success" role="status">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="configuration-savebar">
|
||||
<div className="draft-status">
|
||||
<small>{dirty ? "有未保存的修改" : "所有修改已保存"}</small>
|
||||
</div>
|
||||
<button className="primary" disabled={busy || q.loading}>
|
||||
{busy ? "保存中…" : "保存设置"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import Modal from "../components/Modal";
|
||||
import { useState } from "react";
|
||||
import { api, send, type User } from "../api/client";
|
||||
import { useQuery } from "../hooks/useQuery";
|
||||
import { QueryState } from "../components/Status";
|
||||
export default function Users({ currentUser }: { currentUser: User }) {
|
||||
const q = useQuery<{ data: User[] }>("/users");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [role, setRole] = useState("viewer");
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<span className="eyebrow">ACCESS CONTROL</span>
|
||||
<h1>用户管理</h1>
|
||||
<p>最小权限访问,账号调整后已有会话将失效。</p>
|
||||
</div>
|
||||
<button
|
||||
className="primary"
|
||||
onClick={() => {
|
||||
setError("");
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
+ 添加成员
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{message && (
|
||||
<p className="success" role="status">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
<div className="member-summary">
|
||||
<div>
|
||||
<span>工作台成员</span>
|
||||
<b>{q.data?.data.length || 0}</b>
|
||||
</div>
|
||||
<div>
|
||||
<span>管理员</span>
|
||||
<b>
|
||||
{q.data?.data.filter((v) => v.role === "admin" && !v.disabled)
|
||||
.length || 0}
|
||||
</b>
|
||||
</div>
|
||||
<div>
|
||||
<span>启用账号</span>
|
||||
<b>{q.data?.data.filter((v) => !v.disabled).length || 0}</b>
|
||||
</div>
|
||||
<p>
|
||||
按职责分配权限,
|
||||
<br />
|
||||
协作过程可在审计日志追踪。
|
||||
</p>
|
||||
</div>
|
||||
<div className="panel member-panel">
|
||||
<div className="section-heading">
|
||||
<h2>成员列表</h2>
|
||||
<span className="count-pill">{q.data?.data.length || 0} 位成员</span>
|
||||
</div>
|
||||
<div className="toolbar">
|
||||
<input
|
||||
aria-label="搜索成员"
|
||||
placeholder="搜索用户名…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<small>管理员可调整权限;始终保留至少一名启用的管理员。</small>
|
||||
</div>
|
||||
<QueryState {...q} retry={q.reload} />
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户名</th>
|
||||
<th>角色</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{q.data?.data
|
||||
.filter((u) =>
|
||||
u.username.toLowerCase().includes(query.toLowerCase()),
|
||||
)
|
||||
.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td>
|
||||
<div className="member-identity">
|
||||
<span className="member-avatar">
|
||||
{u.username[0].toUpperCase()}
|
||||
</span>
|
||||
<div>
|
||||
<b>{u.username}</b>
|
||||
<small>
|
||||
{u.id === currentUser.id
|
||||
? "当前账号"
|
||||
: "工作台成员"}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
aria-label={`${u.username} 的角色`}
|
||||
value={u.role}
|
||||
disabled={busy || u.id === currentUser.id}
|
||||
onChange={async (e) => {
|
||||
const next = e.target.value;
|
||||
if (
|
||||
!confirm(
|
||||
`将 ${u.username} 调整为${next === "admin" ? "管理员" : "只读成员"}?已有会话将失效。`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await api(
|
||||
"/users/" + u.id,
|
||||
send("PATCH", {
|
||||
role: next,
|
||||
disabled: u.disabled,
|
||||
}),
|
||||
);
|
||||
setMessage("角色已更新");
|
||||
q.reload();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="admin">管理员</option>
|
||||
<option value="viewer">只读成员</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={
|
||||
"badge " + (u.disabled ? "" : "badge-resolved")
|
||||
}
|
||||
>
|
||||
{u.disabled ? "已禁用" : "启用中"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
disabled={busy || u.id === currentUser.id}
|
||||
title={
|
||||
u.id === currentUser.id
|
||||
? "当前账号不能在这里停用自身"
|
||||
: undefined
|
||||
}
|
||||
onClick={async () => {
|
||||
if (
|
||||
!u.disabled &&
|
||||
!confirm(`停用 ${u.username}?已有会话将失效。`)
|
||||
)
|
||||
return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await api(
|
||||
"/users/" + u.id,
|
||||
send("PATCH", {
|
||||
role: u.role,
|
||||
disabled: !u.disabled,
|
||||
}),
|
||||
);
|
||||
q.reload();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{u.disabled ? "启用" : "禁用"}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div className="permission-note">
|
||||
<span>ⓘ</span>
|
||||
<p>
|
||||
管理员可管理配置、成员与证据;只读成员可查看和导出检测结果。当前账号与最后一名管理员受到保护。
|
||||
</p>
|
||||
</div>
|
||||
{createOpen && (
|
||||
<Modal
|
||||
title="添加成员"
|
||||
busy={busy}
|
||||
onClose={() => {
|
||||
setCreateOpen(false);
|
||||
setPassword("");
|
||||
}}
|
||||
>
|
||||
<form
|
||||
className="form-grid member-create"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await api(
|
||||
"/users",
|
||||
send("POST", { username: name, password, role }),
|
||||
);
|
||||
setMessage("成员已创建,可使用初始密码登录");
|
||||
setCreateOpen(false);
|
||||
setName("");
|
||||
setPassword("");
|
||||
q.reload();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>添加成员</h2>
|
||||
<small>创建一个工作台账号,并指定访问权限。</small>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setCreateOpen(false);
|
||||
setPassword("");
|
||||
}}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<label>
|
||||
用户名
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
minLength={3}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
初始密码
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={12}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
角色
|
||||
<select value={role} onChange={(e) => setRole(e.target.value)}>
|
||||
<option value="viewer">只读成员</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
</label>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<button className="primary" disabled={busy}>
|
||||
创建用户
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
@import "./styles/base.css";
|
||||
@import "./styles/login.css";
|
||||
@import "./styles/layout.css";
|
||||
@import "./styles/components.css";
|
||||
@import "./styles/dashboard.css";
|
||||
@import "./styles/evidence.css";
|
||||
@import "./styles/configuration.css";
|
||||
@import "./styles/members.css";
|
||||
@import "./styles/admin-responsive.css";
|
||||
@@ -0,0 +1,117 @@
|
||||
@media (min-width: 1600px) {
|
||||
.configuration-form {
|
||||
grid-template-columns: minmax(0, 1fr) 310px;
|
||||
}
|
||||
.config-card {
|
||||
padding: 30px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 1050px) {
|
||||
.configuration-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.configuration-rail {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.member-summary {
|
||||
gap: 28px;
|
||||
}
|
||||
.member-summary p {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 650px) {
|
||||
.config-card {
|
||||
padding: 18px;
|
||||
}
|
||||
.config-field-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
.configuration-rail {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.runtime-metrics {
|
||||
gap: 8px;
|
||||
}
|
||||
.runtime-metrics > div {
|
||||
padding: 12px;
|
||||
}
|
||||
.config-card-heading {
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.config-card-heading h2 {
|
||||
font-size: 14px;
|
||||
}
|
||||
.config-card-heading > .badge {
|
||||
margin-left: 48px;
|
||||
}
|
||||
.member-summary {
|
||||
padding: 18px;
|
||||
gap: 30px;
|
||||
}
|
||||
.member-panel {
|
||||
padding: 16px;
|
||||
}
|
||||
.member-panel .toolbar small {
|
||||
display: none;
|
||||
}
|
||||
.configuration-savebar {
|
||||
bottom: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
.config-card-heading .count-pill {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
.config-reload {
|
||||
margin: 0;
|
||||
align-items: center;
|
||||
}
|
||||
.config-reload button {
|
||||
font-size: 11px;
|
||||
padding: 9px 13px;
|
||||
background: #fff9;
|
||||
border-color: #e1e5ee;
|
||||
color: #7f889f;
|
||||
}
|
||||
.config-card-heading > div {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.configuration-form {
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
@media (max-width: 650px) {
|
||||
.config-card-heading {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.config-card-heading > .badge,
|
||||
.config-card-heading > .count-pill {
|
||||
display: none;
|
||||
}
|
||||
.config-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 17px;
|
||||
}
|
||||
.runtime-metrics span {
|
||||
font-size: 9px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.runtime-metrics > div {
|
||||
padding: 10px 7px;
|
||||
}
|
||||
.config-row .subtle-button {
|
||||
font-size: 9px;
|
||||
padding: 6px;
|
||||
}
|
||||
.config-reload button {
|
||||
font-size: 9px;
|
||||
padding: 8px;
|
||||
}
|
||||
.page-heading:has(.config-reload) {
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
:root {
|
||||
font-family: Inter, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
color: #24314a;
|
||||
background: #f4f6fa;
|
||||
font-synthesis: none;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: 1px solid #dce2eb;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
padding: 10px 16px;
|
||||
color: inherit;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: wait;
|
||||
}
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
padding: 12px;
|
||||
border: 1px solid #dce2eb;
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
color: inherit;
|
||||
}
|
||||
input:focus,
|
||||
button:focus-visible {
|
||||
outline: 2px solid #6c72ed;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.primary {
|
||||
background: #6259df;
|
||||
color: white;
|
||||
border-color: #6259df;
|
||||
}
|
||||
.eyebrow {
|
||||
font-size: 11px;
|
||||
letter-spacing: 2px;
|
||||
font-weight: 700;
|
||||
color: #7a8398;
|
||||
}
|
||||
h1 {
|
||||
font-size: 32px;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
p {
|
||||
color: #778299;
|
||||
line-height: 1.8;
|
||||
}
|
||||
small {
|
||||
font-size: 12px;
|
||||
color: #8390a5;
|
||||
}
|
||||
.error {
|
||||
color: #b33c4e;
|
||||
background: #fff0f1;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.loading {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
.page-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
.page-heading h1 {
|
||||
margin: 10px 0;
|
||||
}
|
||||
.page-heading p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.panel {
|
||||
background: #fff;
|
||||
border: 1px solid #e7ebf2;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 3px 10px #24314a03;
|
||||
}
|
||||
.panel h2 {
|
||||
font-size: 17px;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 18px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.stat {
|
||||
background: #fff;
|
||||
border: 1px solid #e7ebf2;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.stat > span {
|
||||
font-size: 13px;
|
||||
color: #7a8398;
|
||||
}
|
||||
.stat strong {
|
||||
font-size: 36px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.guide-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
.guide-grid b {
|
||||
font-size: 13px;
|
||||
}
|
||||
.guide-grid p {
|
||||
font-size: 12px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 22px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.toolbar input {
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
}
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
font-weight: 500;
|
||||
color: #8b95a8;
|
||||
padding: 14px 12px;
|
||||
background: #f8f9fc;
|
||||
white-space: nowrap;
|
||||
}
|
||||
td {
|
||||
padding: 18px 12px;
|
||||
border-bottom: 1px solid #eef0f5;
|
||||
}
|
||||
tbody tr:hover {
|
||||
background: #fafaff;
|
||||
}
|
||||
.text-button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
padding: 0;
|
||||
max-width: 450px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.method {
|
||||
font-size: 10px;
|
||||
color: #6259df;
|
||||
font-weight: 700;
|
||||
background: #eeecff;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.badge {
|
||||
font-size: 10px;
|
||||
padding: 5px 9px;
|
||||
border-radius: 5px;
|
||||
background: #eef1f6;
|
||||
color: #7a8398;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge-true,
|
||||
.badge-confirmed {
|
||||
background: #ffefee;
|
||||
color: #cf635c;
|
||||
}
|
||||
.badge-false,
|
||||
.badge-resolved {
|
||||
background: #eaf6f0;
|
||||
color: #479c79;
|
||||
}
|
||||
.badge-unknown,
|
||||
.badge-pending {
|
||||
background: #fff5df;
|
||||
color: #b78a33;
|
||||
}
|
||||
.badge-error {
|
||||
background: #f9e9f1;
|
||||
color: #b65c81;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 14px;
|
||||
margin-top: 20px;
|
||||
font-size: 12px;
|
||||
color: #8b95a8;
|
||||
}
|
||||
.pagination > span:first-child {
|
||||
margin-right: auto;
|
||||
}
|
||||
.empty {
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
color: #8b95a8;
|
||||
}
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #161d3655;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.detail {
|
||||
background: white;
|
||||
border-radius: 14px;
|
||||
padding: 28px;
|
||||
width: min(1100px, 100%);
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
}
|
||||
.detail h2 {
|
||||
margin: 0;
|
||||
}
|
||||
.url {
|
||||
word-break: break-all;
|
||||
}
|
||||
.comparison {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
.comparison h3 {
|
||||
font-size: 13px;
|
||||
}
|
||||
pre {
|
||||
font-size: 11px;
|
||||
line-height: 1.8;
|
||||
background: #f7f8fc;
|
||||
border: 1px solid #e9ecf3;
|
||||
border-radius: 7px;
|
||||
padding: 16px;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.review-form,
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.form-grid {
|
||||
max-width: 800px;
|
||||
}
|
||||
.form-grid label,
|
||||
.review-form label {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.form-grid textarea {
|
||||
min-height: 100px;
|
||||
resize: vertical;
|
||||
}
|
||||
.form-grid .primary {
|
||||
justify-self: start;
|
||||
}
|
||||
.checkbox {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
}
|
||||
.notice {
|
||||
background: #f1efff;
|
||||
color: #6259df;
|
||||
padding: 14px;
|
||||
font-size: 12px;
|
||||
border-radius: 7px;
|
||||
}
|
||||
.success {
|
||||
background: #eaf6f0;
|
||||
color: #479c79;
|
||||
padding: 12px;
|
||||
border-radius: 7px;
|
||||
}
|
||||
.danger {
|
||||
color: #ba4a56;
|
||||
}
|
||||
.review-form {
|
||||
margin-top: 24px;
|
||||
border-top: 1px solid #e7ebf2;
|
||||
padding-top: 24px;
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
.stat-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.guide-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.page-heading {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.page-heading h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
.panel {
|
||||
padding: 16px;
|
||||
}
|
||||
.stat {
|
||||
padding: 16px;
|
||||
}
|
||||
.comparison {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.modal-backdrop {
|
||||
padding: 8px;
|
||||
}
|
||||
.detail {
|
||||
padding: 18px;
|
||||
}
|
||||
.toolbar select {
|
||||
width: 100%;
|
||||
}
|
||||
.pagination {
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
.configuration-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 280px;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
max-width: 1320px;
|
||||
}
|
||||
.configuration-main {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
.config-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e4e8f0;
|
||||
border-radius: 14px;
|
||||
padding: 26px;
|
||||
box-shadow: 0 3px 12px #24314a03;
|
||||
}
|
||||
.config-card-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.config-card-heading h2 {
|
||||
font-size: 16px;
|
||||
margin: 0 0 7px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.config-card-heading p {
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.config-card-heading > .badge,
|
||||
.config-card-heading > .count-pill {
|
||||
margin-left: auto;
|
||||
}
|
||||
.config-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
background: #f0edfb;
|
||||
color: #8071c7;
|
||||
font-size: 20px;
|
||||
}
|
||||
.config-field {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
margin-top: 18px;
|
||||
min-width: 0;
|
||||
}
|
||||
.config-field input,
|
||||
.config-field textarea {
|
||||
font-size: 12px;
|
||||
background: #fdfdff;
|
||||
border-color: #e2e6ef;
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
font-weight: 400;
|
||||
}
|
||||
.config-field textarea {
|
||||
resize: vertical;
|
||||
min-height: 108px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.config-field small {
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.code-input {
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
}
|
||||
.config-field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 22px;
|
||||
}
|
||||
.config-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid #eef0f5;
|
||||
padding-top: 18px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.switch-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.switch-label input {
|
||||
appearance: none;
|
||||
width: 34px;
|
||||
height: 20px;
|
||||
border: 0;
|
||||
border-radius: 20px;
|
||||
background: #d4d9e5;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
.switch-label input:before {
|
||||
content: "";
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.switch-label input:checked {
|
||||
background: #7364db;
|
||||
}
|
||||
.switch-label input:checked:before {
|
||||
transform: translateX(14px);
|
||||
}
|
||||
.subtle-button {
|
||||
font-size: 11px;
|
||||
padding: 7px 10px;
|
||||
border-color: #e6e8f0;
|
||||
color: #7e879c;
|
||||
}
|
||||
.runtime-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.runtime-metrics > div {
|
||||
background: #f7f8fc;
|
||||
border: 1px solid #eef0f5;
|
||||
border-radius: 9px;
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
.runtime-metrics span {
|
||||
font-size: 10px;
|
||||
color: #9199ac;
|
||||
}
|
||||
.runtime-metrics b {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.count-pill {
|
||||
border-radius: 20px;
|
||||
background: #f2f3f8;
|
||||
color: #9198aa;
|
||||
font-size: 10px;
|
||||
padding: 5px 9px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.configuration-rail {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.help-card {
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(145deg, #f0eefb, #f8f7fc);
|
||||
border: 1px solid #e5e0f5;
|
||||
}
|
||||
.help-card .eyebrow {
|
||||
font-size: 9px;
|
||||
color: #9b8dc1;
|
||||
}
|
||||
.help-card h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.help-card p {
|
||||
font-size: 11px;
|
||||
line-height: 1.8;
|
||||
margin: 8px 0;
|
||||
color: #89859b;
|
||||
}
|
||||
.help-card ol {
|
||||
padding-left: 18px;
|
||||
margin: 24px 0 0;
|
||||
color: #9c8fc5;
|
||||
font-size: 11px;
|
||||
}
|
||||
.help-card li {
|
||||
padding-left: 5px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.help-card li b {
|
||||
color: #716880;
|
||||
font-weight: 500;
|
||||
}
|
||||
.help-card code {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
padding: 8px 10px;
|
||||
background: #fff9;
|
||||
border: 1px solid #e4ddf1;
|
||||
border-radius: 6px;
|
||||
color: #7d6d9e;
|
||||
font-size: 11px;
|
||||
}
|
||||
.help-note {
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #e2dcee;
|
||||
font-size: 10px;
|
||||
color: #9484ac;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.help-card.neutral {
|
||||
background: #f8f9fc;
|
||||
border-color: #e8ebf2;
|
||||
}
|
||||
.help-card.neutral h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
.help-rule {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 10px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #e9ebf3;
|
||||
color: #959cae;
|
||||
}
|
||||
.help-rule b {
|
||||
font-weight: 400;
|
||||
color: #738275;
|
||||
}
|
||||
.help-card.neutral > small {
|
||||
display: block;
|
||||
margin-top: 15px;
|
||||
font-size: 9px;
|
||||
}
|
||||
.advanced-options {
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #edf0f5;
|
||||
font-size: 10px;
|
||||
color: #98a0b2;
|
||||
}
|
||||
.advanced-options summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
.advanced-options summary:before {
|
||||
content: "+ ";
|
||||
color: #9990b8;
|
||||
}
|
||||
.advanced-options[open] summary:before {
|
||||
content: "− ";
|
||||
}
|
||||
.advanced-options .checkbox {
|
||||
margin-top: 16px;
|
||||
font-size: 11px;
|
||||
color: #81899c;
|
||||
}
|
||||
.advanced-options > small {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.configuration-feedback {
|
||||
grid-column: 1/-1;
|
||||
}
|
||||
.configuration-feedback:empty {
|
||||
display: none;
|
||||
}
|
||||
.configuration-feedback p {
|
||||
margin: 0;
|
||||
}
|
||||
.configuration-savebar {
|
||||
grid-column: 1/-1;
|
||||
position: sticky;
|
||||
bottom: 18px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px 22px;
|
||||
background: #ffffffed;
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid #e1e5ef;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 28px #26304b0b;
|
||||
}
|
||||
.configuration-savebar .primary {
|
||||
padding: 10px 24px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.draft-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.draft-status:before {
|
||||
content: "";
|
||||
background: #a0abc0;
|
||||
border-radius: 50%;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
.draft-status small {
|
||||
font-size: 11px;
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
.download {
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
color: #6259df;
|
||||
border: 1px solid #ddd9f5;
|
||||
border-radius: 7px;
|
||||
padding: 10px 12px;
|
||||
display: inline-block;
|
||||
}
|
||||
.account-button {
|
||||
border: 0 !important;
|
||||
background: transparent;
|
||||
padding: 0 !important;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
.readiness-strip {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px 20px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #e4e1f5;
|
||||
background: #f1effb;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.readiness-strip > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.readiness-strip b {
|
||||
font-size: 13px;
|
||||
}
|
||||
.readiness-strip button {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: #6259df;
|
||||
font-size: 12px;
|
||||
}
|
||||
.live-dot {
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
border-radius: 50%;
|
||||
background: #a4a8b7;
|
||||
}
|
||||
.live-dot.running {
|
||||
background: #4caa84;
|
||||
box-shadow: 0 0 0 4px #4caa8418;
|
||||
}
|
||||
.stat-action {
|
||||
text-align: left;
|
||||
}
|
||||
.stat-action > span {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.stat-action:hover {
|
||||
border-color: #b4acec;
|
||||
box-shadow: 0 6px 20px #6259df0b;
|
||||
}
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.6fr 1fr;
|
||||
gap: 22px;
|
||||
}
|
||||
.section-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.section-heading h2 {
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
.section-heading .text-button {
|
||||
color: #6259df;
|
||||
font-size: 12px;
|
||||
}
|
||||
.chart-key {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
font-size: 10px;
|
||||
color: #8b95a8;
|
||||
}
|
||||
.chart-key i {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
background: #a59aeb;
|
||||
}
|
||||
.chart-key i.suspect {
|
||||
background: #ef9f94;
|
||||
}
|
||||
.trend-chart {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
height: 180px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.trend-day {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.trend-count {
|
||||
font-size: 11px;
|
||||
color: #9a9fb0;
|
||||
}
|
||||
.trend-track {
|
||||
height: 130px;
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
border-bottom: 1px solid #e8ebf2;
|
||||
background: repeating-linear-gradient(
|
||||
to top,
|
||||
transparent 0,
|
||||
transparent 31px,
|
||||
#f2f3f8 32px
|
||||
);
|
||||
}
|
||||
.trend-bar {
|
||||
width: 22%;
|
||||
min-height: 2px;
|
||||
background: #a59aeb;
|
||||
border-radius: 3px 3px 0 0;
|
||||
}
|
||||
.trend-bar.suspect {
|
||||
background: #ef9f94;
|
||||
}
|
||||
.trend-day small {
|
||||
font-size: 10px;
|
||||
}
|
||||
.chart-note {
|
||||
font-size: 11px;
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
.distribution {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
.distribution button {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border: 0;
|
||||
padding: 8px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.distribution button > span {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
align-items: center;
|
||||
}
|
||||
.result-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #b9bcc8;
|
||||
}
|
||||
.dot-true {
|
||||
background: #ef9f94;
|
||||
}
|
||||
.dot-false {
|
||||
background: #70b99b;
|
||||
}
|
||||
.dot-unknown {
|
||||
background: #e6c16e;
|
||||
}
|
||||
.dot-error {
|
||||
background: #c788a3;
|
||||
}
|
||||
.review-progress {
|
||||
margin-top: 22px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid #eef0f5;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.review-progress > span {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
progress {
|
||||
width: 100%;
|
||||
height: 7px;
|
||||
accent-color: #8a7edd;
|
||||
}
|
||||
.recent-list button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #eef0f5;
|
||||
border-radius: 0;
|
||||
width: 100%;
|
||||
padding: 14px 0;
|
||||
text-align: left;
|
||||
}
|
||||
.recent-url {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
}
|
||||
.recent-url small {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
margin-top: 7px;
|
||||
}
|
||||
.action-empty {
|
||||
padding: 28px 18px;
|
||||
text-align: center;
|
||||
}
|
||||
.action-empty > span {
|
||||
font-size: 30px;
|
||||
color: #b6afd5;
|
||||
}
|
||||
.action-empty h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.action-empty p {
|
||||
font-size: 12px;
|
||||
max-width: 370px;
|
||||
margin: 12px auto;
|
||||
}
|
||||
.action-empty button {
|
||||
font-size: 12px;
|
||||
color: #6259df;
|
||||
}
|
||||
.target-list button {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
padding: 16px 0;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #eef0f5;
|
||||
border-radius: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.target-list small {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.target-list b {
|
||||
text-align: right;
|
||||
color: #6259df;
|
||||
font-weight: 500;
|
||||
}
|
||||
.setup-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
.setup-grid button {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
text-align: left;
|
||||
background: #fafbfe;
|
||||
}
|
||||
.setup-grid b {
|
||||
font-size: 12px;
|
||||
}
|
||||
.setup-grid small {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
margin-top: 8px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.setup-grid button > span:last-child {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
color: #8c83bc;
|
||||
}
|
||||
.step-marker {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: #eeecf8;
|
||||
color: #968cbf;
|
||||
font-size: 11px;
|
||||
}
|
||||
.step-marker.done {
|
||||
background: #e9f5ef;
|
||||
color: #4ca480;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.setup-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.readiness-strip small {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 850px) {
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.page-heading .toolbar {
|
||||
margin: 0;
|
||||
}
|
||||
.chart-key {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.readiness-strip {
|
||||
padding: 12px;
|
||||
}
|
||||
.setup-grid button {
|
||||
padding: 12px;
|
||||
}
|
||||
.trend-chart {
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
.evidence-tabs {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
border-bottom: 1px solid #e6e9f2;
|
||||
margin: 20px 0;
|
||||
padding-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.evidence-tabs button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
color: #8b95a8;
|
||||
}
|
||||
.evidence-tabs button[aria-selected="true"] {
|
||||
background: #efedff;
|
||||
color: #6259df;
|
||||
}
|
||||
.comparison .section-heading {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.comparison .section-heading button {
|
||||
padding: 5px 8px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.field-diff {
|
||||
background: #fafbfe;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
.field-diff td {
|
||||
max-width: 300px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.bulk-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: #f1effb;
|
||||
border-radius: 8px;
|
||||
margin: 16px 0;
|
||||
font-size: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.bulk-toolbar select {
|
||||
padding: 8px;
|
||||
}
|
||||
.filter-reset {
|
||||
border: 0;
|
||||
color: #7e73bf;
|
||||
font-size: 11px;
|
||||
margin-bottom: 14px;
|
||||
padding: 6px 0;
|
||||
background: transparent;
|
||||
}
|
||||
td > input[type="checkbox"] {
|
||||
margin-right: 12px;
|
||||
accent-color: #6259df;
|
||||
}
|
||||
th > input[type="checkbox"] {
|
||||
accent-color: #6259df;
|
||||
margin-right: 12px;
|
||||
}
|
||||
dialog.native-modal {
|
||||
border: 1px solid #e7ebf2;
|
||||
max-height: 90dvh;
|
||||
max-width: calc(100vw - 32px);
|
||||
box-shadow: 0 24px 80px #171d3540;
|
||||
}
|
||||
dialog.native-modal::backdrop {
|
||||
background: #161d3655;
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
dialog.native-modal .form-grid {
|
||||
max-width: none;
|
||||
}
|
||||
.audit-code {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
color: #a2a8b8;
|
||||
margin-top: 5px;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
.shell {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
aside {
|
||||
width: 238px;
|
||||
background: #fff;
|
||||
border-right: 1px solid #e7ebf2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 30px 18px;
|
||||
position: fixed;
|
||||
inset: 0 auto 0 0;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
padding: 0 10px 44px;
|
||||
}
|
||||
.brand b {
|
||||
background: #6259df;
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
padding: 12px 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.brand span {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
color: #8b95a8;
|
||||
margin-top: 6px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.nav-label {
|
||||
font-size: 10px;
|
||||
color: #9ba5b6;
|
||||
letter-spacing: 1.4px;
|
||||
margin: 0 14px 12px;
|
||||
}
|
||||
nav {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
nav button {
|
||||
border: 0;
|
||||
text-align: left;
|
||||
padding: 13px 16px;
|
||||
color: #778299;
|
||||
background: transparent;
|
||||
}
|
||||
nav button span {
|
||||
display: inline-block;
|
||||
width: 28px;
|
||||
}
|
||||
nav button.active {
|
||||
background: #efedff;
|
||||
color: #6259df;
|
||||
font-weight: 600;
|
||||
}
|
||||
.sidebar-footer {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #eef0f5;
|
||||
}
|
||||
.sidebar-footer small {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.sidebar-footer button {
|
||||
margin-left: auto;
|
||||
border: 0;
|
||||
padding: 8px;
|
||||
}
|
||||
.avatar {
|
||||
background: #e8e7f8;
|
||||
color: #6259df;
|
||||
padding: 12px;
|
||||
border-radius: 9px;
|
||||
}
|
||||
main {
|
||||
margin-left: 238px;
|
||||
width: calc(100% - 238px);
|
||||
}
|
||||
header {
|
||||
height: 76px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e7ebf2;
|
||||
padding: 0 38px;
|
||||
font-size: 12px;
|
||||
color: #8b95a8;
|
||||
}
|
||||
.local-badge {
|
||||
font-size: 10px;
|
||||
letter-spacing: 1px;
|
||||
color: #559680;
|
||||
}
|
||||
.content {
|
||||
padding: 38px;
|
||||
max-width: 1600px;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.login {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
.login-surface {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 460px;
|
||||
}
|
||||
.login-story {
|
||||
display: none;
|
||||
}
|
||||
.login-card {
|
||||
padding: 36px 28px;
|
||||
margin: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
aside {
|
||||
width: 76px;
|
||||
padding: 20px 8px;
|
||||
}
|
||||
.brand {
|
||||
padding: 0 4px 30px;
|
||||
}
|
||||
.brand > div,
|
||||
.nav-label,
|
||||
.sidebar-footer > div,
|
||||
.sidebar-footer small {
|
||||
display: none;
|
||||
}
|
||||
nav button {
|
||||
font-size: 0;
|
||||
padding: 14px;
|
||||
}
|
||||
nav button span {
|
||||
font-size: 20px;
|
||||
width: auto;
|
||||
}
|
||||
.sidebar-footer button {
|
||||
margin: auto;
|
||||
}
|
||||
main {
|
||||
margin-left: 76px;
|
||||
width: calc(100% - 76px);
|
||||
}
|
||||
header {
|
||||
padding: 0 18px;
|
||||
}
|
||||
.local-badge {
|
||||
display: none;
|
||||
}
|
||||
.content {
|
||||
padding: 24px 18px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
.login {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 48px 28px;
|
||||
background:
|
||||
radial-gradient(ellipse at 18% 20%, #e9e6fb 0, transparent 55%),
|
||||
radial-gradient(ellipse at 90% 85%, #edf0fa 0, transparent 50%), #f5f6fa;
|
||||
}
|
||||
.login-surface {
|
||||
width: min(1080px, 100%);
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 1fr;
|
||||
align-items: center;
|
||||
background: linear-gradient(110deg, #f8f7ff 0%, #fff 68%);
|
||||
border: 1px solid #fff;
|
||||
border-radius: 24px;
|
||||
box-shadow:
|
||||
0 24px 80px #33335b10,
|
||||
0 1px 4px #33335b06;
|
||||
overflow: hidden;
|
||||
}
|
||||
.login-story {
|
||||
padding: 64px 56px;
|
||||
color: #24314a;
|
||||
}
|
||||
.login-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 62px;
|
||||
font-weight: 700;
|
||||
font-size: 20px;
|
||||
}
|
||||
.login-brand b {
|
||||
background: #6259df;
|
||||
color: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 12px 9px;
|
||||
font-size: 18px;
|
||||
}
|
||||
.login-brand small {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
margin-top: 5px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.login-story .eyebrow {
|
||||
color: #8c86ad;
|
||||
font-size: 10px;
|
||||
}
|
||||
.login-story h1 {
|
||||
font-size: 40px;
|
||||
line-height: 1.5;
|
||||
margin: 20px 0 22px;
|
||||
letter-spacing: -1.5px;
|
||||
}
|
||||
.login-story p {
|
||||
font-size: 14px;
|
||||
max-width: 350px;
|
||||
color: #81869b;
|
||||
}
|
||||
.login-capabilities {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 9px;
|
||||
margin-top: 36px;
|
||||
}
|
||||
.login-capabilities span {
|
||||
font-size: 10px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #e8e5f4;
|
||||
border-radius: 20px;
|
||||
color: #8a84a4;
|
||||
}
|
||||
.login-card {
|
||||
width: auto;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 52px 54px;
|
||||
border-left: 1px solid #eeedf5;
|
||||
margin: 48px 0;
|
||||
}
|
||||
.login-card h2 {
|
||||
font-size: 30px;
|
||||
margin: 0;
|
||||
}
|
||||
.login-card p {
|
||||
margin: 0;
|
||||
}
|
||||
.login-card label {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.login-card .primary {
|
||||
margin-top: 8px;
|
||||
padding: 14px;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
.member-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 50px;
|
||||
margin-bottom: 24px;
|
||||
padding: 20px 28px;
|
||||
border: 1px solid #e5e8f1;
|
||||
background: linear-gradient(110deg, #f0eefb, #f8f9fc);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.member-summary > div {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.member-summary span {
|
||||
font-size: 10px;
|
||||
color: #9296ac;
|
||||
}
|
||||
.member-summary b {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.member-summary p {
|
||||
margin: 0 0 0 auto;
|
||||
font-size: 11px;
|
||||
color: #9296ac;
|
||||
}
|
||||
.member-panel {
|
||||
padding: 26px;
|
||||
}
|
||||
.member-panel .toolbar input {
|
||||
max-width: 350px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.member-panel .toolbar {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.member-panel .toolbar small {
|
||||
font-size: 10px;
|
||||
}
|
||||
.member-panel table th {
|
||||
background: #f8f9fc;
|
||||
font-size: 11px;
|
||||
}
|
||||
.member-panel table td {
|
||||
padding: 18px 12px;
|
||||
}
|
||||
.member-identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.member-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 9px;
|
||||
background: #edeaf8;
|
||||
color: #9382bd;
|
||||
font-size: 12px;
|
||||
}
|
||||
.member-identity b {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.member-identity small {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.member-panel select {
|
||||
padding: 8px 10px;
|
||||
font-size: 11px;
|
||||
background: #fbfcfe;
|
||||
border-color: #e9ecf3;
|
||||
}
|
||||
.member-panel td > button {
|
||||
font-size: 11px;
|
||||
padding: 7px 12px;
|
||||
color: #8b94a9;
|
||||
}
|
||||
.permission-note {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 0 6px;
|
||||
color: #a0a8bb;
|
||||
}
|
||||
.permission-note p {
|
||||
font-size: 10px;
|
||||
line-height: 1.8;
|
||||
margin: 0;
|
||||
}
|
||||
.native-modal:has(.member-create) {
|
||||
width: min(480px, calc(100vw - 32px));
|
||||
}
|
||||
.member-create {
|
||||
gap: 22px;
|
||||
}
|
||||
.member-create .section-heading {
|
||||
margin: 0;
|
||||
}
|
||||
.member-create label {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.member-create input,
|
||||
.member-create select {
|
||||
font-size: 12px;
|
||||
}
|
||||
.member-create .primary {
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
test.describe.configure({ mode: "serial" });
|
||||
test("initialize, navigate, configure, manage access, export, change password", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("heading", { name: "创建管理员" })).toBeVisible();
|
||||
await page.getByLabel("用户名").fill("testadmin");
|
||||
await page.getByLabel("密码", { exact: true }).fill("fictional-password-123");
|
||||
await page.getByRole("button", { name: "初始化工作台" }).click();
|
||||
await expect(page.getByRole("heading", { name: "欢迎回来" })).toBeVisible();
|
||||
await page.getByLabel("密码", { exact: true }).fill("fictional-password-123");
|
||||
await page.getByRole("button", { name: "登录工作台" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "安全态势概览" }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "检测结果", exact: false }).click();
|
||||
await expect(page.getByText("暂无记录")).toBeVisible();
|
||||
await page.getByRole("button", { name: "系统设置", exact: false }).click();
|
||||
await expect(page.getByLabel("模型端点")).toBeVisible();
|
||||
await expect(page).toHaveURL(/#\/settings$/);
|
||||
await page.reload();
|
||||
await expect(page.getByLabel("模型端点")).toBeVisible();
|
||||
await page.goBack();
|
||||
await expect(page.getByText("暂无记录")).toBeVisible();
|
||||
await page.goForward();
|
||||
await expect(page.getByLabel("模型端点")).toBeVisible();
|
||||
await page.screenshot({
|
||||
path: "../data/settings-redesign.png",
|
||||
fullPage: true,
|
||||
});
|
||||
await page.getByRole("button", { name: "扫描控制", exact: false }).click();
|
||||
await expect(page.getByLabel("目标域名")).toBeVisible();
|
||||
await page.screenshot({
|
||||
path: "../data/scanner-redesign.png",
|
||||
fullPage: true,
|
||||
});
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.screenshot({
|
||||
path: "../data/scanner-redesign-mobile.png",
|
||||
fullPage: true,
|
||||
});
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.getByLabel("目标域名").fill("example.test");
|
||||
await page.getByLabel("目标域名").press("End");
|
||||
await page.getByLabel("目标域名").press("Enter");
|
||||
await page.getByLabel("目标域名").pressSequentially("api.example.test");
|
||||
await expect(page.getByLabel("目标域名")).toHaveValue(
|
||||
"example.test\napi.example.test",
|
||||
);
|
||||
|
||||
page.once("dialog", (dialog) => dialog.dismiss());
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/#\/scanner$/);
|
||||
await expect(page.getByLabel("目标域名")).toHaveValue(
|
||||
"example.test\napi.example.test",
|
||||
);
|
||||
page.once("dialog", (dialog) => dialog.dismiss());
|
||||
await page.getByRole("button", { name: "系统设置", exact: false }).click();
|
||||
await expect(page.getByRole("heading", { name: "扫描控制" })).toBeVisible();
|
||||
await expect(page.getByLabel("目标域名")).toHaveValue(
|
||||
"example.test\napi.example.test",
|
||||
);
|
||||
|
||||
await page
|
||||
.getByRole("textbox", { name: "账号 B 请求头" })
|
||||
.fill('{"Authorization":"Bearer fictional-fixture"}');
|
||||
await page.getByRole("button", { name: "保存设置" }).click();
|
||||
await expect(page.getByRole("status")).toHaveText("设置已保存");
|
||||
await page.getByRole("button", { name: "用户管理", exact: false }).click();
|
||||
await page.screenshot({ path: "../data/users-redesign.png", fullPage: true });
|
||||
await page.getByRole("button", { name: "添加成员", exact: false }).click();
|
||||
await page.getByLabel("用户名").fill("viewer");
|
||||
await page.getByLabel("初始密码").fill("fictional-viewer-123");
|
||||
await page.getByRole("button", { name: "创建用户" }).click();
|
||||
await expect(
|
||||
page.getByRole("cell").filter({ hasText: "viewer" }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "审计日志", exact: false }).click();
|
||||
await expect(
|
||||
page.getByRole("cell").filter({ hasText: "create_user" }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "检测结果", exact: false }).click();
|
||||
const download = page.waitForEvent("download");
|
||||
await page.getByRole("link", { name: "导出 CSV" }).click();
|
||||
expect((await download).suggestedFilename()).toBe("privhunter-results.csv");
|
||||
await page.getByRole("button", { name: "testadmin", exact: true }).click();
|
||||
await expect(page.getByRole("dialog", { name: "修改密码" })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "testadmin", exact: true }),
|
||||
).toBeFocused();
|
||||
await page.getByRole("button", { name: "testadmin", exact: true }).click();
|
||||
await page.getByLabel("当前密码").fill("fictional-password-123");
|
||||
await page.getByLabel("新密码").fill("fictional-changed-123");
|
||||
await page.getByRole("button", { name: "保存新密码" }).click();
|
||||
await expect(page.getByRole("heading", { name: "欢迎回来" })).toBeVisible();
|
||||
await page.getByLabel("用户名").fill("viewer");
|
||||
await page.getByLabel("密码", { exact: true }).fill("fictional-viewer-123");
|
||||
await page.getByRole("button", { name: "登录工作台" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "安全态势概览" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "用户管理", exact: false }),
|
||||
).toHaveCount(0);
|
||||
await page.goto("/#/users");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "安全态势概览" }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(/#\/overview$/);
|
||||
await page.goto("/#/findings");
|
||||
await expect(page.getByText("暂无记录")).toBeVisible();
|
||||
await page.getByRole("button", { name: "概览", exact: false }).click();
|
||||
await page.screenshot({ path: "../data/e2e-desktop.png", fullPage: true });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.screenshot({ path: "../data/e2e-mobile.png", fullPage: true });
|
||||
const denied = await page.request.get("/api/users");
|
||||
expect(denied.status()).toBe(403);
|
||||
await page.getByRole("button", { name: "退出登录" }).click();
|
||||
await expect(page.getByRole("heading", { name: "欢迎回来" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("proxy evidence appears in UI, review and reload persistence", async ({
|
||||
page,
|
||||
}) => {
|
||||
const { createServer, request } = await import("node:http");
|
||||
const target = createServer((req, res) => {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.statusCode =
|
||||
req.headers.authorization === "Bearer synthetic-b" ? 403 : 200;
|
||||
res.end(JSON.stringify({ id: 1, token: "synthetic-hidden" }));
|
||||
});
|
||||
await new Promise<void>((resolve) => target.listen(0, "127.0.0.1", resolve));
|
||||
try {
|
||||
await page.goto("/");
|
||||
await page.getByLabel("用户名").fill("testadmin");
|
||||
await page
|
||||
.getByLabel("密码", { exact: true })
|
||||
.fill("fictional-changed-123");
|
||||
await page.getByRole("button", { name: "登录工作台" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "安全态势概览" }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "扫描控制", exact: false }).click();
|
||||
await page.getByLabel("目标域名").fill("127.0.0.1");
|
||||
await page
|
||||
.getByRole("textbox", { name: "账号 B 请求头" })
|
||||
.fill('{"Authorization":"Bearer synthetic-b"}');
|
||||
await page.getByLabel("启用扫描").check();
|
||||
await page.getByRole("button", { name: "保存设置" }).click();
|
||||
await expect(page.getByRole("status")).toHaveText("设置已保存");
|
||||
const addr = target.address() as { port: number };
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = request(
|
||||
{
|
||||
hostname: "127.0.0.1",
|
||||
port: 19080,
|
||||
path: `http://127.0.0.1:${addr.port}/orders`,
|
||||
headers: { Authorization: "Bearer synthetic-a" },
|
||||
},
|
||||
(res) => {
|
||||
res.resume();
|
||||
res.on("end", resolve);
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
await page.getByRole("button", { name: "检测结果", exact: false }).click();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
await page.getByRole("button", { name: "刷新", exact: true }).click();
|
||||
return page.getByRole("button", { name: /GET.*orders/ }).count();
|
||||
})
|
||||
.toBe(1);
|
||||
await page.getByRole("button", { name: /GET.*orders/ }).click();
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("[REDACTED]", { exact: false }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("synthetic-hidden", { exact: false }),
|
||||
).toHaveCount(0);
|
||||
await page
|
||||
.getByRole("dialog")
|
||||
.getByRole("combobox")
|
||||
.selectOption("confirmed");
|
||||
await page.getByLabel("备注").fill("已复核测试证据");
|
||||
await page.getByRole("button", { name: "保存复核" }).click();
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("cell").filter({ hasText: "已确认" }),
|
||||
).toBeVisible();
|
||||
await page.reload();
|
||||
await page.getByRole("button", { name: "检测结果", exact: false }).click();
|
||||
await expect(
|
||||
page.getByRole("cell").filter({ hasText: "已确认" }),
|
||||
).toBeVisible();
|
||||
await page.screenshot({ path: "../data/e2e-findings.png", fullPage: true });
|
||||
await page
|
||||
.getByRole("button", { name: "概览", exact: false })
|
||||
.first()
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: /已确认风险/ }),
|
||||
).toContainText("1");
|
||||
await page.getByRole("button", { name: /已确认风险/ }).click();
|
||||
await expect(
|
||||
page.getByRole("combobox", { name: "复核状态", exact: true }),
|
||||
).toHaveValue("confirmed");
|
||||
await page.getByRole("checkbox", { name: "选择当前页全部记录" }).check();
|
||||
await page
|
||||
.getByRole("combobox", { name: "批量复核状态" })
|
||||
.selectOption("false_positive");
|
||||
await page.getByRole("button", { name: "应用复核状态" }).click();
|
||||
await expect(page.getByRole("status")).toHaveText("已更新 1 条记录");
|
||||
await page.getByRole("button", { name: "清除筛选条件" }).click();
|
||||
await expect(
|
||||
page.getByRole("cell").filter({ hasText: "误报" }),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => target.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: { proxy: { "/api": "http://127.0.0.1:8222" } },
|
||||
});
|
||||
Reference in new issue
Block a user