From 03ce1f3c4264f4e38ac8fa97937c83484231db8c Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Sat, 1 Aug 2026 23:25:42 +0000 Subject: [PATCH] feat: Add Integrations page with API token management New Integrations page in Wild Central UI for managing API tokens. Provides token display, copy, and rotation so Wild Cloud and other services can easily retrieve their authentication credentials. - GET /api/v1/integrations returns current API token - POST /api/v1/integrations/api-token/rotate generates a new token - Frontend with show/hide toggle, copy button, and usage instructions - Added to sidebar navigation under Central section --- internal/api/v1/handlers.go | 4 + internal/api/v1/handlers_integrations.go | 44 ++++++ web/src/components/AppSidebar.tsx | 5 +- web/src/components/IntegrationsComponent.tsx | 150 +++++++++++++++++++ web/src/router/pages/IntegrationsPage.tsx | 10 ++ web/src/router/routes.tsx | 2 + web/src/services/api/integrations.ts | 23 +++ 7 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 internal/api/v1/handlers_integrations.go create mode 100644 web/src/components/IntegrationsComponent.tsx create mode 100644 web/src/router/pages/IntegrationsPage.tsx create mode 100644 web/src/services/api/integrations.ts diff --git a/internal/api/v1/handlers.go b/internal/api/v1/handlers.go index 24dd22f..877c2bc 100644 --- a/internal/api/v1/handlers.go +++ b/internal/api/v1/handlers.go @@ -278,6 +278,10 @@ func (api *API) RegisterRoutes(r *mux.Router, bearerToken string, devMode bool) r.HandleFunc("/api/v1/secrets", api.GetGlobalSecrets).Methods("GET") r.HandleFunc("/api/v1/secrets", api.UpdateGlobalSecrets).Methods("PUT") + // Integrations (API token management) + r.HandleFunc("/api/v1/integrations", api.GetIntegrations).Methods("GET") + r.HandleFunc("/api/v1/integrations/api-token/rotate", api.RotateAPIToken).Methods("POST") + // Daemon control r.HandleFunc("/api/v1/daemon/restart", api.DaemonRestart).Methods("POST") diff --git a/internal/api/v1/handlers_integrations.go b/internal/api/v1/handlers_integrations.go new file mode 100644 index 0000000..4c23c1d --- /dev/null +++ b/internal/api/v1/handlers_integrations.go @@ -0,0 +1,44 @@ +package v1 + +import ( + "log/slog" + "net/http" + + "github.com/wild-cloud/wild-central/internal/secrets" +) + +// GetIntegrations returns integration info including the API bearer token. +func (api *API) GetIntegrations(w http.ResponseWriter, r *http.Request) { + token, err := api.secrets.GetSecret("api.bearerToken") + if err != nil { + token = "" + } + + respondJSON(w, http.StatusOK, map[string]any{ + "apiToken": map[string]any{ + "token": token, + "description": "Bearer token for authenticating with the Wild Central API. Used by Wild Cloud and other services.", + }, + }) +} + +// RotateAPIToken generates a new API bearer token and saves it to secrets. +func (api *API) RotateAPIToken(w http.ResponseWriter, r *http.Request) { + token, err := secrets.GenerateSecret(secrets.DefaultSecretLength) + if err != nil { + respondError(w, http.StatusInternalServerError, "Failed to generate token") + return + } + + if err := api.secrets.SetSecret("api.bearerToken", token); err != nil { + respondError(w, http.StatusInternalServerError, "Failed to save token") + return + } + + slog.Info("API bearer token rotated") + + respondJSON(w, http.StatusOK, map[string]any{ + "token": token, + "message": "API token rotated. Services using the old token will need to be updated.", + }) +} diff --git a/web/src/components/AppSidebar.tsx b/web/src/components/AppSidebar.tsx index b5e3dc5..3f19d49 100644 --- a/web/src/components/AppSidebar.tsx +++ b/web/src/components/AppSidebar.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { NavLink } from 'react-router'; -import { Sun, Moon, Monitor, Shield, Lock, ShieldAlert, ShieldBan, ShieldCheck, Wifi, LayoutDashboard, Globe, Network, ChevronRight, KeyRound } from 'lucide-react'; +import { Sun, Moon, Monitor, Shield, Lock, ShieldAlert, ShieldBan, ShieldCheck, Wifi, LayoutDashboard, Globe, Network, ChevronRight, KeyRound, Plug } from 'lucide-react'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible'; import { Sidebar, @@ -95,6 +95,7 @@ export function AppSidebar() { { to: '/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const }, { to: '/dhcp', icon: Wifi, label: 'DHCP', daemon: 'dnsmasq' as const }, { to: '/dns-filter', icon: ShieldBan, label: 'DNS Filter', daemon: 'dnsmasq' as const }, + { to: '/integrations', icon: Plug, label: 'Integrations', daemon: undefined }, ]; const advancedItems = [ @@ -171,7 +172,7 @@ export function AppSidebar() { {centralItems.map(({ to, icon: Icon, label, daemon }) => { - const active = centralStatus?.daemons?.[daemon]?.active; + const active = daemon ? centralStatus?.daemons?.[daemon]?.active : undefined; return ( diff --git a/web/src/components/IntegrationsComponent.tsx b/web/src/components/IntegrationsComponent.tsx new file mode 100644 index 0000000..0ce6746 --- /dev/null +++ b/web/src/components/IntegrationsComponent.tsx @@ -0,0 +1,150 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Key, RotateCw, Loader2, AlertCircle, CheckCircle, Eye, EyeOff } from 'lucide-react'; +import { Card } from './ui/card'; +import { Button } from './ui/button'; +import { Badge } from './ui/badge'; +import { Alert, AlertDescription } from './ui/alert'; +import { CopyButton } from './CopyButton'; +import { integrationsApi } from '../services/api/integrations'; + +export function IntegrationsComponent() { + const queryClient = useQueryClient(); + const [showToken, setShowToken] = useState(false); + const [successMessage, setSuccessMessage] = useState(null); + + const { data, isLoading, error } = useQuery({ + queryKey: ['integrations'], + queryFn: () => integrationsApi.get(), + }); + + const rotateMutation = useMutation({ + mutationFn: () => integrationsApi.rotateApiToken(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['integrations'] }); + setSuccessMessage('API token rotated. Update the token in any connected services.'); + setTimeout(() => setSuccessMessage(null), 8000); + }, + }); + + if (isLoading) { + return ( + +
+ +
+
+ ); + } + + if (error) { + return ( + + +

Error Loading Integrations

+

{(error as Error)?.message || 'An error occurred'}

+
+ ); + } + + const token = data?.apiToken?.token || ''; + const maskedToken = token ? `${token.slice(0, 6)}${'*'.repeat(Math.max(0, token.length - 6))}` : ''; + + return ( +
+
+
+ +
+
+

Integrations

+

+ Manage API tokens and service connections +

+
+
+ + {successMessage && ( + + + {successMessage} + + )} + + {rotateMutation.error && ( + + + {(rotateMutation.error as Error)?.message || 'Failed to rotate token'} + + )} + + +
+
+

API Token

+

+ Bearer token for authenticating with the Wild Central API +

+
+ + {token ? ( + <> Configured + ) : ( + <> Not Set + )} + +
+ + {token && ( + <> +
+ + {showToken ? token : maskedToken} + + + +
+ +
+

Usage

+

+ Services like Wild Cloud use this token to authenticate with Wild Central's API. + Set it as the WILD_CENTRAL_TOKEN environment variable. +

+ + WILD_CENTRAL_TOKEN={showToken ? token : maskedToken} + +
+ + )} + +
+ + {token && ( +

+ Rotating the token will invalidate the current one. Connected services will need to be updated. +

+ )} +
+
+
+ ); +} diff --git a/web/src/router/pages/IntegrationsPage.tsx b/web/src/router/pages/IntegrationsPage.tsx new file mode 100644 index 0000000..6d575a2 --- /dev/null +++ b/web/src/router/pages/IntegrationsPage.tsx @@ -0,0 +1,10 @@ +import { ErrorBoundary } from '../../components'; +import { IntegrationsComponent } from '../../components/IntegrationsComponent'; + +export function IntegrationsPage() { + return ( + + + + ); +} diff --git a/web/src/router/routes.tsx b/web/src/router/routes.tsx index 97a9093..752ebf4 100644 --- a/web/src/router/routes.tsx +++ b/web/src/router/routes.tsx @@ -9,6 +9,7 @@ import { CrowdSecPage } from './pages/CrowdSecPage'; import { DhcpPage } from './pages/DhcpPage'; import { DnsFilterPage } from './pages/DnsFilterPage'; import { AutheliaPage } from './pages/AutheliaPage'; +import { IntegrationsPage } from './pages/IntegrationsPage'; import { HaproxyPage, DnsmasqPage, NftablesPage, WireguardPage, CrowdsecPage as CrowdsecAdvancedPage, @@ -30,6 +31,7 @@ export const routes: RouteObject[] = [ { path: 'auth', element: }, { path: 'dhcp', element: }, { path: 'dns-filter', element: }, + { path: 'integrations', element: }, { path: 'advanced/haproxy', element: }, { path: 'advanced/dnsmasq', element: }, { path: 'advanced/nftables', element: }, diff --git a/web/src/services/api/integrations.ts b/web/src/services/api/integrations.ts new file mode 100644 index 0000000..c00caa4 --- /dev/null +++ b/web/src/services/api/integrations.ts @@ -0,0 +1,23 @@ +import { apiClient } from './client'; + +export interface IntegrationsResponse { + apiToken: { + token: string; + description: string; + }; +} + +export interface RotateTokenResponse { + token: string; + message: string; +} + +export const integrationsApi = { + async get(): Promise { + return apiClient.get('/api/v1/integrations'); + }, + + async rotateApiToken(): Promise { + return apiClient.post('/api/v1/integrations/api-token/rotate'); + }, +};