feat(settings): add global blacklist management page and UI integration

- Add new global blacklist settings page with pattern management interface
- Create useGlobalBlacklist and useUpdateGlobalBlacklist React Query hooks for data fetching and mutations
- Implement global-blacklist.service.ts with API integration for blacklist operations
- Add Global Blacklist navigation item to app sidebar with Ban icon
- Add internationalization support for blacklist UI with English and Chinese translations
- Include pattern matching rules documentation (domain wildcards, keywords, IP addresses, CIDR ranges)
- Add loading states, error handling, and success/error toast notifications
- Implement textarea input with change tracking and save button state management
This commit is contained in:
yyhuni
2026-01-06 11:50:31 +08:00
parent 2a3d9b4446
commit d1ec9b7f27
6 changed files with 257 additions and 2 deletions

View File

@@ -0,0 +1,132 @@
"use client"
import React, { useState, useEffect } from "react"
import { useTranslations } from "next-intl"
import { AlertTriangle, Loader2, Ban } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { Skeleton } from "@/components/ui/skeleton"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { useGlobalBlacklist, useUpdateGlobalBlacklist } from "@/hooks/use-global-blacklist"
/**
* Global blacklist settings page
*/
export default function GlobalBlacklistPage() {
const t = useTranslations("pages.settings.blacklist")
const [blacklistText, setBlacklistText] = useState("")
const [hasChanges, setHasChanges] = useState(false)
const { data, isLoading, error } = useGlobalBlacklist()
const updateBlacklist = useUpdateGlobalBlacklist()
// Initialize text when data loads
useEffect(() => {
if (data?.patterns) {
setBlacklistText(data.patterns.join("\n"))
setHasChanges(false)
}
}, [data])
// Handle text change
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setBlacklistText(e.target.value)
setHasChanges(true)
}
// Handle save
const handleSave = () => {
const patterns = blacklistText
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0)
updateBlacklist.mutate(
{ patterns },
{
onSuccess: () => {
setHasChanges(false)
},
}
)
}
if (isLoading) {
return (
<div className="flex flex-1 flex-col gap-4 p-4">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-96" />
</div>
<Skeleton className="h-[400px] w-full" />
</div>
)
}
if (error) {
return (
<div className="flex flex-1 flex-col items-center justify-center py-12">
<AlertTriangle className="h-10 w-10 text-destructive mb-4" />
<p className="text-muted-foreground">{t("loadError")}</p>
</div>
)
}
return (
<div className="flex flex-1 flex-col gap-4 p-4">
{/* Page header */}
<div>
<h1 className="text-2xl font-bold">{t("title")}</h1>
<p className="text-muted-foreground">{t("description")}</p>
</div>
{/* Blacklist card */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Ban className="h-5 w-5 text-muted-foreground" />
<CardTitle>{t("card.title")}</CardTitle>
</div>
<CardDescription>{t("card.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Rules hint */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">{t("rules.title")}:</span>
<span><code className="bg-muted px-1.5 py-0.5 rounded text-xs">*.gov</code> {t("rules.domain")}</span>
<span><code className="bg-muted px-1.5 py-0.5 rounded text-xs">*cdn*</code> {t("rules.keyword")}</span>
<span><code className="bg-muted px-1.5 py-0.5 rounded text-xs">192.168.1.1</code> {t("rules.ip")}</span>
<span><code className="bg-muted px-1.5 py-0.5 rounded text-xs">10.0.0.0/8</code> {t("rules.cidr")}</span>
</div>
{/* Scope hint */}
<div className="rounded-lg border bg-muted/50 p-3 text-sm">
<p className="text-muted-foreground">{t("scopeHint")}</p>
</div>
{/* Input */}
<Textarea
value={blacklistText}
onChange={handleTextChange}
placeholder={t("placeholder")}
className="min-h-[320px] font-mono text-sm"
/>
{/* Save button */}
<div className="flex justify-end">
<Button
onClick={handleSave}
disabled={!hasChanges || updateBlacklist.isPending}
>
{updateBlacklist.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t("save")}
</Button>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -18,6 +18,7 @@ import {
IconMessageReport, // Feedback icon
IconSearch, // Search icon
IconKey, // API Key icon
IconBan, // Blacklist icon
} from "@tabler/icons-react"
// Import internationalization hook
import { useTranslations } from 'next-intl'
@@ -174,6 +175,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
url: "/settings/api-keys/",
icon: IconKey,
},
{
name: t('globalBlacklist'),
url: "/settings/blacklist/",
icon: IconBan,
},
]
return (

View File

@@ -0,0 +1,40 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { useTranslations } from 'next-intl'
import {
getGlobalBlacklist,
updateGlobalBlacklist,
type GlobalBlacklistResponse,
type UpdateGlobalBlacklistRequest,
} from '@/services/global-blacklist.service'
const QUERY_KEY = ['global-blacklist']
/**
* Hook to fetch global blacklist
*/
export function useGlobalBlacklist() {
return useQuery<GlobalBlacklistResponse>({
queryKey: QUERY_KEY,
queryFn: getGlobalBlacklist,
})
}
/**
* Hook to update global blacklist
*/
export function useUpdateGlobalBlacklist() {
const queryClient = useQueryClient()
const t = useTranslations('pages.settings.blacklist')
return useMutation({
mutationFn: (data: UpdateGlobalBlacklistRequest) => updateGlobalBlacklist(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY })
toast.success(t('toast.saveSuccess'))
},
onError: () => {
toast.error(t('toast.saveError'))
},
})
}

View File

@@ -320,6 +320,7 @@
"systemLogs": "System Logs",
"notifications": "Notifications",
"apiKeys": "API Keys",
"globalBlacklist": "Global Blacklist",
"help": "Get Help",
"feedback": "Feedback"
},
@@ -2053,7 +2054,7 @@
"cidr": "Matches IP range",
"cidrShort": "CIDR"
},
"placeholder": "Enter rules, one per line",
"placeholder": "Enter rules, one per line\n\nExamples:\n*.gov\n*.edu\n*cdn*\n192.168.0.0/16\n10.0.0.1",
"save": "Save Rules"
},
"scheduledScans": {
@@ -2137,6 +2138,31 @@
"nav": {
"scanEngine": "Scan Engine",
"wordlists": "Wordlist Management"
},
"settings": {
"blacklist": {
"title": "Global Blacklist",
"description": "Configure global blacklist rules. Matching assets will be automatically excluded during scans.",
"loadError": "Failed to load blacklist rules",
"card": {
"title": "Blacklist Rules",
"description": "These rules apply to all target scans. To configure blacklist for a specific target, go to the target settings page."
},
"rules": {
"title": "Supported rule types",
"domain": "Domain",
"keyword": "Keyword",
"ip": "IP",
"cidr": "CIDR"
},
"scopeHint": "Global rules apply to all targets. Target-level rules can be configured in Target → Settings.",
"placeholder": "Enter rules, one per line\n\nExamples:\n*.gov\n*.edu\n*cdn*\n192.168.0.0/16\n10.0.0.1",
"save": "Save Rules",
"toast": {
"saveSuccess": "Blacklist rules saved",
"saveError": "Failed to save blacklist rules"
}
}
}
},
"metadata": {

View File

@@ -320,6 +320,7 @@
"systemLogs": "系统日志",
"notifications": "通知设置",
"apiKeys": "API 密钥",
"globalBlacklist": "全局黑名单",
"help": "获取帮助",
"feedback": "反馈建议"
},
@@ -2053,7 +2054,7 @@
"cidr": "匹配 IP 网段范围",
"cidrShort": "CIDR"
},
"placeholder": "输入规则,每行一个",
"placeholder": "输入规则,每行一个\n\n示例\n*.gov\n*.edu\n*cdn*\n192.168.0.0/16\n10.0.0.1",
"save": "保存规则"
},
"scheduledScans": {
@@ -2137,6 +2138,31 @@
"nav": {
"scanEngine": "扫描引擎",
"wordlists": "字典管理"
},
"settings": {
"blacklist": {
"title": "全局黑名单",
"description": "配置全局黑名单规则,扫描时将自动排除匹配的资产",
"loadError": "加载黑名单规则失败",
"card": {
"title": "黑名单规则",
"description": "这些规则将应用于所有目标的扫描任务。如需为特定目标配置黑名单,请前往目标设置页面。"
},
"rules": {
"title": "支持的规则类型",
"domain": "域名",
"keyword": "关键词",
"ip": "IP",
"cidr": "CIDR"
},
"scopeHint": "全局规则对所有目标生效。目标级规则可在「目标 → 设置」中单独配置。",
"placeholder": "输入规则,每行一个\n\n示例\n*.gov\n*.edu\n*cdn*\n192.168.0.0/16\n10.0.0.1",
"save": "保存规则",
"toast": {
"saveSuccess": "黑名单规则已保存",
"saveError": "保存黑名单规则失败"
}
}
}
},
"metadata": {

View File

@@ -0,0 +1,25 @@
import { api } from '@/lib/api-client'
export interface GlobalBlacklistResponse {
patterns: string[]
}
export interface UpdateGlobalBlacklistRequest {
patterns: string[]
}
/**
* Get global blacklist rules
*/
export async function getGlobalBlacklist(): Promise<GlobalBlacklistResponse> {
const res = await api.get<GlobalBlacklistResponse>('/blacklist/rules/')
return res.data
}
/**
* Update global blacklist rules (full replace)
*/
export async function updateGlobalBlacklist(data: UpdateGlobalBlacklistRequest): Promise<GlobalBlacklistResponse> {
const res = await api.put<GlobalBlacklistResponse>('/blacklist/rules/', data)
return res.data
}