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)