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
This commit is contained in:
2026-08-01 23:25:42 +00:00
parent 21b963b8ec
commit 03ce1f3c42
7 changed files with 236 additions and 2 deletions

View File

@@ -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.GetGlobalSecrets).Methods("GET")
r.HandleFunc("/api/v1/secrets", api.UpdateGlobalSecrets).Methods("PUT") 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 // Daemon control
r.HandleFunc("/api/v1/daemon/restart", api.DaemonRestart).Methods("POST") r.HandleFunc("/api/v1/daemon/restart", api.DaemonRestart).Methods("POST")

View File

@@ -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.",
})
}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { NavLink } from 'react-router'; 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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
import { import {
Sidebar, Sidebar,
@@ -95,6 +95,7 @@ export function AppSidebar() {
{ to: '/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const }, { to: '/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const },
{ to: '/dhcp', icon: Wifi, label: 'DHCP', daemon: 'dnsmasq' 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: '/dns-filter', icon: ShieldBan, label: 'DNS Filter', daemon: 'dnsmasq' as const },
{ to: '/integrations', icon: Plug, label: 'Integrations', daemon: undefined },
]; ];
const advancedItems = [ const advancedItems = [
@@ -171,7 +172,7 @@ export function AppSidebar() {
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{centralItems.map(({ to, icon: Icon, label, daemon }) => { {centralItems.map(({ to, icon: Icon, label, daemon }) => {
const active = centralStatus?.daemons?.[daemon]?.active; const active = daemon ? centralStatus?.daemons?.[daemon]?.active : undefined;
return ( return (
<SidebarMenuItem key={to}> <SidebarMenuItem key={to}>
<NavLink to={to}> <NavLink to={to}>

View File

@@ -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<string | null>(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 (
<Card className="p-6">
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</Card>
);
}
if (error) {
return (
<Card className="p-8 text-center">
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">Error Loading Integrations</h3>
<p className="text-muted-foreground">{(error as Error)?.message || 'An error occurred'}</p>
</Card>
);
}
const token = data?.apiToken?.token || '';
const maskedToken = token ? `${token.slice(0, 6)}${'*'.repeat(Math.max(0, token.length - 6))}` : '';
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<div className="p-2 bg-primary/10 rounded-lg">
<Key className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">Integrations</h2>
<p className="text-muted-foreground">
Manage API tokens and service connections
</p>
</div>
</div>
{successMessage && (
<Alert className="border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/20">
<CheckCircle className="h-4 w-4 text-green-600" />
<AlertDescription className="text-green-700 dark:text-green-300">{successMessage}</AlertDescription>
</Alert>
)}
{rotateMutation.error && (
<Alert variant="error">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{(rotateMutation.error as Error)?.message || 'Failed to rotate token'}</AlertDescription>
</Alert>
)}
<Card className="p-6 border-l-4 border-l-blue-500">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold">API Token</h3>
<p className="text-sm text-muted-foreground mt-1">
Bearer token for authenticating with the Wild Central API
</p>
</div>
<Badge variant="outline" className="gap-1">
{token ? (
<><CheckCircle className="h-3 w-3 text-green-500" /> Configured</>
) : (
<><AlertCircle className="h-3 w-3 text-red-500" /> Not Set</>
)}
</Badge>
</div>
{token && (
<>
<div className="flex items-center gap-2 mb-4">
<code className="flex-1 bg-muted rounded-md p-3 font-mono text-sm break-all">
{showToken ? token : maskedToken}
</code>
<Button
variant="ghost"
size="icon"
onClick={() => setShowToken(!showToken)}
title={showToken ? 'Hide token' : 'Show token'}
>
{showToken ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
<CopyButton content={token} label="Copy" />
</div>
<div className="bg-muted/50 rounded-md p-4 mb-4">
<p className="text-sm font-medium mb-2">Usage</p>
<p className="text-sm text-muted-foreground mb-3">
Services like Wild Cloud use this token to authenticate with Wild Central's API.
Set it as the <code className="font-mono bg-muted px-1 rounded">WILD_CENTRAL_TOKEN</code> environment variable.
</p>
<code className="block bg-muted rounded-md p-2 font-mono text-xs break-all">
WILD_CENTRAL_TOKEN={showToken ? token : maskedToken}
</code>
</div>
</>
)}
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => rotateMutation.mutate()}
disabled={rotateMutation.isPending}
>
{rotateMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RotateCw className="h-4 w-4" />
)}
{token ? 'Rotate Token' : 'Generate Token'}
</Button>
{token && (
<p className="text-xs text-muted-foreground">
Rotating the token will invalidate the current one. Connected services will need to be updated.
</p>
)}
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { ErrorBoundary } from '../../components';
import { IntegrationsComponent } from '../../components/IntegrationsComponent';
export function IntegrationsPage() {
return (
<ErrorBoundary>
<IntegrationsComponent />
</ErrorBoundary>
);
}

View File

@@ -9,6 +9,7 @@ import { CrowdSecPage } from './pages/CrowdSecPage';
import { DhcpPage } from './pages/DhcpPage'; import { DhcpPage } from './pages/DhcpPage';
import { DnsFilterPage } from './pages/DnsFilterPage'; import { DnsFilterPage } from './pages/DnsFilterPage';
import { AutheliaPage } from './pages/AutheliaPage'; import { AutheliaPage } from './pages/AutheliaPage';
import { IntegrationsPage } from './pages/IntegrationsPage';
import { import {
HaproxyPage, DnsmasqPage, NftablesPage, WireguardPage, HaproxyPage, DnsmasqPage, NftablesPage, WireguardPage,
CrowdsecPage as CrowdsecAdvancedPage, CrowdsecPage as CrowdsecAdvancedPage,
@@ -30,6 +31,7 @@ export const routes: RouteObject[] = [
{ path: 'auth', element: <AutheliaPage /> }, { path: 'auth', element: <AutheliaPage /> },
{ path: 'dhcp', element: <DhcpPage /> }, { path: 'dhcp', element: <DhcpPage /> },
{ path: 'dns-filter', element: <DnsFilterPage /> }, { path: 'dns-filter', element: <DnsFilterPage /> },
{ path: 'integrations', element: <IntegrationsPage /> },
{ path: 'advanced/haproxy', element: <HaproxyPage /> }, { path: 'advanced/haproxy', element: <HaproxyPage /> },
{ path: 'advanced/dnsmasq', element: <DnsmasqPage /> }, { path: 'advanced/dnsmasq', element: <DnsmasqPage /> },
{ path: 'advanced/nftables', element: <NftablesPage /> }, { path: 'advanced/nftables', element: <NftablesPage /> },

View File

@@ -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<IntegrationsResponse> {
return apiClient.get('/api/v1/integrations');
},
async rotateApiToken(): Promise<RotateTokenResponse> {
return apiClient.post('/api/v1/integrations/api-token/rotate');
},
};