From e51999c9088c0dc8a6ee7117a11ef38cd06b1a7b Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 8 Jul 2026 23:27:29 +0700 Subject: [PATCH] feat(firewall): add revoke functionality for firewall clients and enhance status badge 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. --- apps/web/src/components/status-badge.tsx | 5 ++ apps/web/src/queries/firewall.ts | 17 ++++++ apps/web/src/routes/_auth/firewall.tsx | 75 ++++++++++++++++++++++-- docs/openapi.yaml | 25 ++++++++ internal/httpapi/routes_firewall_test.go | 68 +++++++++++++++++++++ 5 files changed, 184 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/status-badge.tsx b/apps/web/src/components/status-badge.tsx index 5a94e6a..5941a46 100644 --- a/apps/web/src/components/status-badge.tsx +++ b/apps/web/src/components/status-badge.tsx @@ -22,6 +22,11 @@ const STATUS_VARIANT: Record = { 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 }) { diff --git a/apps/web/src/queries/firewall.ts b/apps/web/src/queries/firewall.ts index 9508a7f..14d697e 100644 --- a/apps/web/src/queries/firewall.ts +++ b/apps/web/src/queries/firewall.ts @@ -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(`/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 : 'Не удалось отклонить'), }) } diff --git a/apps/web/src/routes/_auth/firewall.tsx b/apps/web/src/routes/_auth/firewall.tsx index 5ce3c99..0fee8ff 100644 --- a/apps/web/src/routes/_auth/firewall.tsx +++ b/apps/web/src/routes/_auth/firewall.tsx @@ -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() { - approve.mutate(id)} /> + approve.mutate(id)} + onReject={(id) => revoke.mutate(id)} + approvePending={approve.isPending} + rejectPending={revoke.isPending} + /> @@ -254,6 +263,9 @@ function FirewallPage() { approve.mutate(id)} + onReject={(id) => revoke.mutate(id)} + approvePending={approve.isPending} + rejectPending={revoke.isPending} emptyTitle="Нет pending-запросов" /> @@ -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})` : ''} - {c.status === 'pending' ? ( - - ) : null} +
+ {c.status === 'pending' ? ( + <> + + + Отклонить + + } + title="Отклонить запрос?" + description={`${c.name}${c.hostname ? ` (${c.hostname})` : ''} — токен перестанет работать.`} + confirmLabel="Отклонить" + destructive + onConfirm={() => onReject(c.id)} + /> + + ) : null} + {c.status === 'approved' ? ( + + Отозвать + + } + title="Отозвать клиент?" + description={`${c.name} — blocklist перестанет отдаваться, токен будет недействителен.`} + confirmLabel="Отозвать" + destructive + onConfirm={() => onReject(c.id)} + /> + ) : null} +
))} diff --git a/docs/openapi.yaml b/docs/openapi.yaml index eea8aa1..e1662d9 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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] diff --git a/internal/httpapi/routes_firewall_test.go b/internal/httpapi/routes_firewall_test.go index ae0a640..b68d6b2 100644 --- a/internal/httpapi/routes_firewall_test.go +++ b/internal/httpapi/routes_firewall_test.go @@ -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)