白名单加白
This commit is contained in:
parent
2215b01743
commit
4166fe0786
@ -1,16 +1,25 @@
|
|||||||
|
import AddModeratorOutlined from "@mui/icons-material/AddModeratorOutlined";
|
||||||
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { toPageQuery } from "@/shared/api/query";
|
import { toPageQuery } from "@/shared/api/query";
|
||||||
|
import { FormValidationError, parseForm } from "@/shared/forms/validation";
|
||||||
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
|
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||||
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||||
import { mergePaginatedItems } from "@/shared/hooks/usePaginatedQuery.js";
|
import { mergePaginatedItems } from "@/shared/hooks/usePaginatedQuery.js";
|
||||||
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
||||||
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
||||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import { listAppUserLoginLogs } from "@/features/app-users/api";
|
import { listAppUserLoginLogs } from "@/features/app-users/api";
|
||||||
|
import { addRegionBlockWhitelistIP } from "@/features/region-blocks/api";
|
||||||
|
import { useRegionBlockAbilities } from "@/features/region-blocks/permissions.js";
|
||||||
|
import { regionBlockIPSchema } from "@/features/region-blocks/schema";
|
||||||
import styles from "@/features/app-users/app-users.module.css";
|
import styles from "@/features/app-users/app-users.module.css";
|
||||||
|
|
||||||
const pageSize = 50;
|
const pageSize = 50;
|
||||||
@ -23,10 +32,14 @@ const resultOptions = [
|
|||||||
|
|
||||||
export function AppUserLoginLogsPage() {
|
export function AppUserLoginLogsPage() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const regionBlockAbilities = useRegionBlockAbilities();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [result, setResult] = useState("");
|
const [result, setResult] = useState("");
|
||||||
const [regionId, setRegionId] = useState("");
|
const [regionId, setRegionId] = useState("");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [addingWhitelistIP, setAddingWhitelistIP] = useState("");
|
||||||
const [mergedData, setMergedData] = useState(emptyData);
|
const [mergedData, setMergedData] = useState(emptyData);
|
||||||
const { loadingRegions, regionOptions } = useRegionOptions();
|
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||||
const userFilter = searchParams.get("user_id") || "";
|
const userFilter = searchParams.get("user_id") || "";
|
||||||
@ -132,6 +145,27 @@ export function AppUserLoginLogsPage() {
|
|||||||
[changeQuery, setSearchParams, userFilter],
|
[changeQuery, setSearchParams, userFilter],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const addLoginIPToWhitelist = useCallback(
|
||||||
|
async (log) => {
|
||||||
|
if (!regionBlockAbilities.canUpdate || addingWhitelistIP) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ipAddress = parseForm(regionBlockIPSchema, log.loginIp);
|
||||||
|
setAddingWhitelistIP(ipAddress);
|
||||||
|
const result = await addRegionBlockWhitelistIP(ipAddress);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["region-blocks"] });
|
||||||
|
showToast(result.added ? "IP 已添加到白名单" : "白名单 IP 已存在", result.added ? "success" : "warning");
|
||||||
|
} catch (err) {
|
||||||
|
showValidationError(err, showToast, "添加白名单 IP 失败");
|
||||||
|
} finally {
|
||||||
|
setAddingWhitelistIP("");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addingWhitelistIP, queryClient, regionBlockAbilities.canUpdate, showToast],
|
||||||
|
);
|
||||||
|
|
||||||
const selectedUserLabel = useMemo(() => {
|
const selectedUserLabel = useMemo(() => {
|
||||||
if (!userFilter) {
|
if (!userFilter) {
|
||||||
return "";
|
return "";
|
||||||
@ -233,8 +267,23 @@ export function AppUserLoginLogsPage() {
|
|||||||
width: "180px",
|
width: "180px",
|
||||||
render: (log) => <span className="muted">{formatMillis(log.createdAtMs)}</span>,
|
render: (log) => <span className="muted">{formatMillis(log.createdAtMs)}</span>,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "操作",
|
||||||
|
width: "120px",
|
||||||
|
render: (log) => (
|
||||||
|
<LoginLogActions
|
||||||
|
canUpdateWhitelist={regionBlockAbilities.canUpdate}
|
||||||
|
addingWhitelistIP={addingWhitelistIP}
|
||||||
|
log={log}
|
||||||
|
onAddToWhitelist={addLoginIPToWhitelist}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
addLoginIPToWhitelist,
|
||||||
|
addingWhitelistIP,
|
||||||
changeIdentityFilter,
|
changeIdentityFilter,
|
||||||
changeRegionId,
|
changeRegionId,
|
||||||
changeResult,
|
changeResult,
|
||||||
@ -244,6 +293,7 @@ export function AppUserLoginLogsPage() {
|
|||||||
regionId,
|
regionId,
|
||||||
resetIdentityFilter,
|
resetIdentityFilter,
|
||||||
result,
|
result,
|
||||||
|
regionBlockAbilities.canUpdate,
|
||||||
selectedUserLabel,
|
selectedUserLabel,
|
||||||
userFilter,
|
userFilter,
|
||||||
],
|
],
|
||||||
@ -258,7 +308,7 @@ export function AppUserLoginLogsPage() {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
emptyLabel="暂无登录日志"
|
emptyLabel="暂无登录日志"
|
||||||
items={mergedData.items || []}
|
items={mergedData.items || []}
|
||||||
minWidth="1380px"
|
minWidth="1500px"
|
||||||
getRowProps={(log) => loginLogRowProps(log, showUserHistory)}
|
getRowProps={(log) => loginLogRowProps(log, showUserHistory)}
|
||||||
rowKey={(log) => log.id}
|
rowKey={(log) => log.id}
|
||||||
pagination={
|
pagination={
|
||||||
@ -279,6 +329,33 @@ export function AppUserLoginLogsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LoginLogActions({ addingWhitelistIP, canUpdateWhitelist, log, onAddToWhitelist }) {
|
||||||
|
const ipAddress = String(log.loginIp || "").trim();
|
||||||
|
const adding = Boolean(addingWhitelistIP) && addingWhitelistIP === ipAddress;
|
||||||
|
const disabled = !canUpdateWhitelist || !ipAddress || Boolean(addingWhitelistIP);
|
||||||
|
const title = !canUpdateWhitelist ? "无白名单更新权限" : ipAddress ? "添加IP到白名单" : "该日志没有IP";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.rowActions}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onKeyDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Tooltip arrow title={title}>
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
disabled={disabled}
|
||||||
|
label="添加IP到白名单"
|
||||||
|
onClick={() => onAddToWhitelist(log)}
|
||||||
|
>
|
||||||
|
<AddModeratorOutlined color={adding ? "primary" : "inherit"} fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function loginLogRowProps(log, onOpenHistory) {
|
function loginLogRowProps(log, onOpenHistory) {
|
||||||
if (!log.userId) {
|
if (!log.userId) {
|
||||||
return {};
|
return {};
|
||||||
@ -297,6 +374,14 @@ function loginLogRowProps(log, onOpenHistory) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showValidationError(err, showToast, fallback) {
|
||||||
|
if (err instanceof FormValidationError) {
|
||||||
|
showToast(err.message, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showToast(err?.message || fallback, "error");
|
||||||
|
}
|
||||||
|
|
||||||
function ResultBadge({ log }) {
|
function ResultBadge({ log }) {
|
||||||
const result = String(log.result || "").toLowerCase();
|
const result = String(log.result || "").toLowerCase();
|
||||||
const blocked = log.blocked || result === "blocked";
|
const blocked = log.blocked || result === "blocked";
|
||||||
|
|||||||
@ -24,6 +24,11 @@ export interface RegionBlockPayload {
|
|||||||
whitelist_ips?: string[];
|
whitelist_ips?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AddRegionBlockWhitelistIPResult {
|
||||||
|
added: boolean;
|
||||||
|
config: RegionBlockConfigDto;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RegionBlockConfigDto extends ApiList<RegionBlockDto> {
|
export interface RegionBlockConfigDto extends ApiList<RegionBlockDto> {
|
||||||
whitelistItems: IPWhitelistDto[];
|
whitelistItems: IPWhitelistDto[];
|
||||||
whitelistTotal: number;
|
whitelistTotal: number;
|
||||||
@ -51,6 +56,24 @@ export function replaceRegionBlocks(payload: RegionBlockPayload): Promise<Region
|
|||||||
).then(normalizeRegionBlockConfig);
|
).then(normalizeRegionBlockConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function addRegionBlockWhitelistIP(ipAddress: string): Promise<AddRegionBlockWhitelistIPResult> {
|
||||||
|
const current = await listRegionBlocks();
|
||||||
|
const keywords = (current.items || []).map((item) => item.keyword).filter(Boolean);
|
||||||
|
const whitelistIPs = (current.whitelistItems || []).map((item) => item.ipAddress).filter(Boolean);
|
||||||
|
const normalizedIP = stringValue(ipAddress).trim();
|
||||||
|
|
||||||
|
// 后端当前只提供整份配置替换接口;快捷追加前先读取最新配置并合并,避免把已保存的屏蔽词或其他 IP 覆盖掉。
|
||||||
|
if (whitelistIPs.some((item) => normalizeKey(item) === normalizeKey(normalizedIP))) {
|
||||||
|
return { added: false, config: current };
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await replaceRegionBlocks({
|
||||||
|
keywords,
|
||||||
|
whitelist_ips: [...whitelistIPs, normalizedIP],
|
||||||
|
});
|
||||||
|
return { added: true, config };
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeRegionBlockConfig(data: RawRegionBlockConfig): RegionBlockConfigDto {
|
function normalizeRegionBlockConfig(data: RawRegionBlockConfig): RegionBlockConfigDto {
|
||||||
const rawWhitelist = (data.whitelistItems ?? data.whitelist_items ?? []) as RawIPWhitelist[];
|
const rawWhitelist = (data.whitelistItems ?? data.whitelist_items ?? []) as RawIPWhitelist[];
|
||||||
return {
|
return {
|
||||||
@ -86,6 +109,10 @@ function stringValue(value: unknown) {
|
|||||||
return value === undefined || value === null ? "" : String(value);
|
return value === undefined || value === null ? "" : String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeKey(value: unknown) {
|
||||||
|
return String(value || "").trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
function numberValue(value: unknown) {
|
function numberValue(value: unknown) {
|
||||||
const number = Number(value || 0);
|
const number = Number(value || 0);
|
||||||
return Number.isFinite(number) ? number : 0;
|
return Number.isFinite(number) ? number : 0;
|
||||||
|
|||||||
@ -17,6 +17,7 @@ export function useRegionBlockPage() {
|
|||||||
const [draftKeywords, setDraftKeywords] = useState([]);
|
const [draftKeywords, setDraftKeywords] = useState([]);
|
||||||
const [draftWhitelistIPs, setDraftWhitelistIPs] = useState([]);
|
const [draftWhitelistIPs, setDraftWhitelistIPs] = useState([]);
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const [whitelistDialogOpen, setWhitelistDialogOpen] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const {
|
const {
|
||||||
data = emptyData,
|
data = emptyData,
|
||||||
@ -188,6 +189,7 @@ export function useRegionBlockPage() {
|
|||||||
ipInput,
|
ipInput,
|
||||||
keywordInput,
|
keywordInput,
|
||||||
loading,
|
loading,
|
||||||
|
openWhitelistDialog: () => setWhitelistDialogOpen(true),
|
||||||
reload,
|
reload,
|
||||||
removeIP,
|
removeIP,
|
||||||
removeKeyword,
|
removeKeyword,
|
||||||
@ -198,6 +200,8 @@ export function useRegionBlockPage() {
|
|||||||
setKeywordInput,
|
setKeywordInput,
|
||||||
submitIPInput,
|
submitIPInput,
|
||||||
submitInput,
|
submitInput,
|
||||||
|
whitelistDialogOpen,
|
||||||
|
closeWhitelistDialog: () => setWhitelistDialogOpen(false),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,13 @@
|
|||||||
import AddOutlined from "@mui/icons-material/AddOutlined";
|
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||||
|
import FormatListBulletedOutlined from "@mui/icons-material/FormatListBulletedOutlined";
|
||||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||||
import RestartAltOutlined from "@mui/icons-material/RestartAltOutlined";
|
import RestartAltOutlined from "@mui/icons-material/RestartAltOutlined";
|
||||||
import SaveOutlined from "@mui/icons-material/SaveOutlined";
|
import SaveOutlined from "@mui/icons-material/SaveOutlined";
|
||||||
|
import Dialog from "@mui/material/Dialog";
|
||||||
|
import DialogActions from "@mui/material/DialogActions";
|
||||||
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import Tooltip from "@mui/material/Tooltip";
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
import { Button } from "@/shared/ui/Button.jsx";
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
@ -88,6 +93,13 @@ export function RegionBlockPage() {
|
|||||||
添加IP
|
添加IP
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
<Button
|
||||||
|
disabled={page.loading}
|
||||||
|
startIcon={<FormatListBulletedOutlined fontSize="small" />}
|
||||||
|
onClick={page.openWhitelistDialog}
|
||||||
|
>
|
||||||
|
白名单IP列表
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -113,6 +125,7 @@ export function RegionBlockPage() {
|
|||||||
</div>
|
</div>
|
||||||
</AdminListBody>
|
</AdminListBody>
|
||||||
</DataState>
|
</DataState>
|
||||||
|
<WhitelistDialog page={page} />
|
||||||
</AdminListPage>
|
</AdminListPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -195,6 +208,54 @@ function buildWhitelistColumns(page) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildWhitelistDialogColumns() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "ipAddress",
|
||||||
|
label: "IP",
|
||||||
|
width: "minmax(220px, 1fr)",
|
||||||
|
render: (item) => <KeywordCell item={{ keyword: item.ipAddress, enabled: item.enabled }} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "updatedAtMs",
|
||||||
|
label: "更新时间",
|
||||||
|
width: "minmax(180px, 0.8fr)",
|
||||||
|
render: (item) => (item.updatedAtMs ? formatMillis(item.updatedAtMs) : "-"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function WhitelistDialog({ page }) {
|
||||||
|
const columns = buildWhitelistDialogColumns();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
maxWidth="md"
|
||||||
|
open={page.whitelistDialogOpen}
|
||||||
|
slotProps={{ paper: { className: styles.whitelistDialogPaper } }}
|
||||||
|
fullWidth
|
||||||
|
onClose={page.closeWhitelistDialog}
|
||||||
|
>
|
||||||
|
<DialogTitle className={styles.whitelistDialogTitle}>
|
||||||
|
<span>白名单IP列表</span>
|
||||||
|
<small>共 {page.draftWhitelistItems.length} 个</small>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent className={styles.whitelistDialogContent}>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
emptyLabel="当前无数据"
|
||||||
|
items={page.draftWhitelistItems}
|
||||||
|
minWidth="520px"
|
||||||
|
rowKey={(item) => item.whitelistId || item.ipAddress}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions className={styles.whitelistDialogActions}>
|
||||||
|
<Button onClick={page.closeWhitelistDialog}>关闭</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function KeywordCell({ item }) {
|
function KeywordCell({ item }) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.keywordCell}>
|
<div className={styles.keywordCell}>
|
||||||
|
|||||||
@ -56,6 +56,50 @@
|
|||||||
font-size: var(--admin-font-size);
|
font-size: var(--admin-font-size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.whitelistDialogPaper {
|
||||||
|
max-height: min(720px, calc(100dvh - 48px));
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whitelistDialogTitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whitelistDialogTitle small {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: var(--admin-font-size);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whitelistDialogContent {
|
||||||
|
display: flex;
|
||||||
|
min-height: 320px;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whitelistDialogContent :global(.table-frame) {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whitelistDialogContent :global(.table-scroll) {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whitelistDialogActions {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.forms {
|
.forms {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user