Compare commits

...
1 Commits
Author SHA1 Message Date
Denozordec e51999c908 feat(firewall): add revoke functionality for firewall clients and enhance status badge
CI / changes (push) Successful in 13s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 30s
CI / web (push) Successful in 56s
CI / go (push) Successful in 1m11s
CI / bird2 (push) Successful in 27s
CI / release (push) Successful in 4m19s
Implemented the ability to revoke approved firewall clients and reject pending requests through new API endpoints. Updated the StatusBadge component to include additional status variants for 'approved', 'revoked', 'pending', and 'block'. Enhanced the FirewallPage UI to support client revocation and rejection actions, integrating confirmation dialogs for user interactions. Updated tests to ensure proper functionality of the new revoke feature.
2026-07-08 23:27:29 +07:00
5 changed files with 184 additions and 6 deletions
+5
View File
@@ -22,6 +22,11 @@ const STATUS_VARIANT: Record<string, BadgeVariant> = {
stale: 'warning',
warning: 'warning',
mismatch: 'warning',
pending: 'warning',
approved: 'success',
revoked: 'destructive',
block: 'destructive',
accept: 'success',
}
export function StatusBadge({ status, label }: { status: string; label?: string }) {
+17
View File
@@ -1,4 +1,6 @@
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { apiJSON } from '@/lib/api-client'
import type {
FirewallClient,
@@ -51,8 +53,23 @@ export function useApproveFirewallClient() {
mutationFn: (id: string) =>
apiJSON<FirewallClient>(`/v1/firewall/clients/${id}/approve`, { method: 'POST' }),
onSuccess: () => {
toast.success('Клиент одобрен')
void qc.invalidateQueries({ queryKey: firewallKeys.clients() })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось одобрить'),
})
}
export function useRevokeFirewallClient() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
apiJSON<{ status: string }>(`/v1/firewall/clients/${id}/revoke`, { method: 'POST' }),
onSuccess: () => {
toast.success('Клиент отключён')
void qc.invalidateQueries({ queryKey: firewallKeys.clients() })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отклонить'),
})
}
+69 -6
View File
@@ -19,6 +19,7 @@ import {
TableRow,
} from '@evobgp/ui/components/table'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { PageHeader } from '@/components/page-header'
import { CommunitySelect } from '@/components/modules/community-select'
import { StatusBadge } from '@/components/status-badge'
@@ -31,6 +32,7 @@ import {
useApproveFirewallClient,
useCreateFirewallRule,
useDeleteFirewallRule,
useRevokeFirewallClient,
} from '@/queries/firewall'
import type { BgpCommunity, FirewallClient } from '@/types/api'
@@ -54,6 +56,7 @@ function FirewallPage() {
const clientsQ = useQuery(firewallClientsQueryOptions())
const rulesQ = useQuery(firewallRulesQueryOptions('tenant'))
const approve = useApproveFirewallClient()
const revoke = useRevokeFirewallClient()
const createRule = useCreateFirewallRule()
const deleteRule = useDeleteFirewallRule()
@@ -193,7 +196,13 @@ function FirewallPage() {
</TabsList>
<TabsContent value="clients" className="mt-4">
<ClientsTable clients={clients} onApprove={(id) => approve.mutate(id)} />
<ClientsTable
clients={clients}
onApprove={(id) => approve.mutate(id)}
onReject={(id) => revoke.mutate(id)}
approvePending={approve.isPending}
rejectPending={revoke.isPending}
/>
</TabsContent>
<TabsContent value="rules" className="mt-4 space-y-4">
@@ -254,6 +263,9 @@ function FirewallPage() {
<ClientsTable
clients={pending}
onApprove={(id) => approve.mutate(id)}
onReject={(id) => revoke.mutate(id)}
approvePending={approve.isPending}
rejectPending={revoke.isPending}
emptyTitle="Нет pending-запросов"
/>
</TabsContent>
@@ -265,10 +277,16 @@ function FirewallPage() {
function ClientsTable({
clients,
onApprove,
onReject,
approvePending = false,
rejectPending = false,
emptyTitle = 'Нет клиентов',
}: {
clients: FirewallClient[]
onApprove: (id: string) => void
onReject: (id: string) => void
approvePending?: boolean
rejectPending?: boolean
emptyTitle?: string
}) {
if (clients.length === 0) {
@@ -301,11 +319,56 @@ function ClientsTable({
{c.last_apply_prefix_count != null ? ` (${c.last_apply_prefix_count})` : ''}
</TableCell>
<TableCell>
{c.status === 'pending' ? (
<Button size="sm" variant="outline" onClick={() => onApprove(c.id)}>
Approve
</Button>
) : null}
<div className="flex justify-end gap-2">
{c.status === 'pending' ? (
<>
<Button
size="sm"
variant="outline"
disabled={approvePending}
onClick={() => onApprove(c.id)}
>
Одобрить
</Button>
<ConfirmDialog
trigger={
<Button
size="sm"
variant="outline"
className="text-destructive"
disabled={rejectPending}
>
Отклонить
</Button>
}
title="Отклонить запрос?"
description={`${c.name}${c.hostname ? ` (${c.hostname})` : ''} — токен перестанет работать.`}
confirmLabel="Отклонить"
destructive
onConfirm={() => onReject(c.id)}
/>
</>
) : null}
{c.status === 'approved' ? (
<ConfirmDialog
trigger={
<Button
size="sm"
variant="ghost"
className="text-destructive"
disabled={rejectPending}
>
Отозвать
</Button>
}
title="Отозвать клиент?"
description={`${c.name} — blocklist перестанет отдаваться, токен будет недействителен.`}
confirmLabel="Отозвать"
destructive
onConfirm={() => onReject(c.id)}
/>
) : null}
</div>
</TableCell>
</TableRow>
))}
+25
View File
@@ -4472,6 +4472,31 @@ paths:
default:
$ref: "#/components/responses/DefaultProblem"
/v1/firewall/clients/{id}/revoke:
post:
tags: [Firewall]
summary: Reject pending or revoke approved client
operationId: revokeFirewallClient
parameters:
- name: id
in: path
required: true
schema:
$ref: "#/components/schemas/ResourceId"
responses:
"200":
description: Revoked
content:
application/json:
schema:
type: object
properties:
status:
type: string
enum: [revoked]
default:
$ref: "#/components/responses/DefaultProblem"
/v1/firewall/rules:
get:
tags: [Firewall]
+68
View File
@@ -205,6 +205,74 @@ func TestFirewallInstallContext(t *testing.T) {
}
}
func TestFirewallRevokePendingClient(t *testing.T) {
srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
tenant, _, _, _, _ := srv.Store().DemoIDs()
mustSetTestAPIKeys(t, srv, "opkey|"+tenant+"|operator")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
client := ts.Client()
tok := "evobgp_fw_revoketest123456789012345678901"
enrollBody := `{"name":"reject-me","hostname":"test.local","client_token":"` + tok + `","client_version":"test/1"}`
reqEnroll, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/firewall/enroll", strings.NewReader(enrollBody))
reqEnroll.Header.Set("Content-Type", "application/json")
reqEnroll.Header.Set("X-EvoBGP-Seed", testBundleSeed)
respEnroll, err := client.Do(reqEnroll)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respEnroll.Body.Close() }()
if respEnroll.StatusCode != http.StatusCreated {
b, _ := io.ReadAll(respEnroll.Body)
t.Fatalf("enroll status=%d body=%s", respEnroll.StatusCode, b)
}
var enroll map[string]any
if err := json.NewDecoder(respEnroll.Body).Decode(&enroll); err != nil {
t.Fatal(err)
}
clientID, _ := enroll["client_id"].(string)
if clientID == "" {
t.Fatal("missing client_id")
}
reqRevoke, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/firewall/clients/"+clientID+"/revoke", nil)
reqRevoke.Header.Set("Authorization", "Bearer opkey")
respRevoke, err := client.Do(reqRevoke)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respRevoke.Body.Close() }()
if respRevoke.StatusCode != http.StatusOK {
b, _ := io.ReadAll(respRevoke.Body)
t.Fatalf("revoke status=%d body=%s", respRevoke.StatusCode, b)
}
got, err := srv.Store().GetFirewallClient(tenant, clientID)
if err != nil {
t.Fatal(err)
}
if got.Status != "revoked" {
t.Fatalf("status=%q want revoked", got.Status)
}
reqBlock, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/firewall/blocklist", nil)
reqBlock.Header.Set("Authorization", "Bearer "+tok)
respBlock, err := client.Do(reqBlock)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respBlock.Body.Close() }()
if respBlock.StatusCode != http.StatusForbidden {
t.Fatalf("revoked blocklist want 403 got %d", respBlock.StatusCode)
}
}
func TestFirewallTokenHashMatchesAuthkey(t *testing.T) {
tok := "evobgp_fw_sample"
h := authkey.HashToken(tok)