Compare commits
3 Commits
21b963b8ec
...
d5457ae2dc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5457ae2dc | ||
|
|
a90af10932 | ||
|
|
03ce1f3c42 |
@@ -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")
|
||||||
|
|
||||||
|
|||||||
44
internal/api/v1/handlers_integrations.go
Normal file
44
internal/api/v1/handlers_integrations.go
Normal 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.",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -164,19 +164,26 @@ func parseCertOutput(output string, status *CertStatus) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// certName returns the name certbot uses to store a certificate.
|
||||||
|
// Certbot strips the "*." prefix from wildcard domains, so
|
||||||
|
// *.example.com is stored under "example.com".
|
||||||
|
func certName(domain string) string {
|
||||||
|
return strings.TrimPrefix(domain, "*.")
|
||||||
|
}
|
||||||
|
|
||||||
// CertPath returns the fullchain.pem path for a domain.
|
// CertPath returns the fullchain.pem path for a domain.
|
||||||
func CertPath(domain string) string {
|
func CertPath(domain string) string {
|
||||||
return fmt.Sprintf("/etc/letsencrypt/live/%s/fullchain.pem", domain)
|
return fmt.Sprintf("/etc/letsencrypt/live/%s/fullchain.pem", certName(domain))
|
||||||
}
|
}
|
||||||
|
|
||||||
// KeyPath returns the privkey.pem path for a domain.
|
// KeyPath returns the privkey.pem path for a domain.
|
||||||
func KeyPath(domain string) string {
|
func KeyPath(domain string) string {
|
||||||
return fmt.Sprintf("/etc/letsencrypt/live/%s/privkey.pem", domain)
|
return fmt.Sprintf("/etc/letsencrypt/live/%s/privkey.pem", certName(domain))
|
||||||
}
|
}
|
||||||
|
|
||||||
// HAProxyCertPath returns the combined PEM path for HAProxy TLS termination.
|
// HAProxyCertPath returns the combined PEM path for HAProxy TLS termination.
|
||||||
func HAProxyCertPath(domain string) string {
|
func HAProxyCertPath(domain string) string {
|
||||||
return fmt.Sprintf("/etc/haproxy/certs/%s.pem", domain)
|
return fmt.Sprintf("/etc/haproxy/certs/%s.pem", certName(domain))
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildHAProxyCert concatenates fullchain.pem + privkey.pem into a single PEM
|
// BuildHAProxyCert concatenates fullchain.pem + privkey.pem into a single PEM
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func TestHAProxyCertPath(t *testing.T) {
|
|||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{"example.com", "/etc/haproxy/certs/example.com.pem"},
|
{"example.com", "/etc/haproxy/certs/example.com.pem"},
|
||||||
{"*.example.com", "/etc/haproxy/certs/*.example.com.pem"},
|
{"*.example.com", "/etc/haproxy/certs/example.com.pem"},
|
||||||
{"sub.example.com", "/etc/haproxy/certs/sub.example.com.pem"},
|
{"sub.example.com", "/etc/haproxy/certs/sub.example.com.pem"},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -34,15 +34,31 @@ func TestHAProxyCertPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCertPaths(t *testing.T) {
|
func TestCertPaths(t *testing.T) {
|
||||||
domain := "example.com"
|
tests := []struct {
|
||||||
certPath := CertPath(domain)
|
domain string
|
||||||
keyPath := KeyPath(domain)
|
wantCert string
|
||||||
|
wantKey string
|
||||||
if certPath != "/etc/letsencrypt/live/example.com/fullchain.pem" {
|
}{
|
||||||
t.Errorf("CertPath = %q", certPath)
|
{
|
||||||
|
"example.com",
|
||||||
|
"/etc/letsencrypt/live/example.com/fullchain.pem",
|
||||||
|
"/etc/letsencrypt/live/example.com/privkey.pem",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"*.example.com",
|
||||||
|
"/etc/letsencrypt/live/example.com/fullchain.pem",
|
||||||
|
"/etc/letsencrypt/live/example.com/privkey.pem",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if keyPath != "/etc/letsencrypt/live/example.com/privkey.pem" {
|
for _, tt := range tests {
|
||||||
t.Errorf("KeyPath = %q", keyPath)
|
certPath := CertPath(tt.domain)
|
||||||
|
keyPath := KeyPath(tt.domain)
|
||||||
|
if certPath != tt.wantCert {
|
||||||
|
t.Errorf("CertPath(%q) = %q, want %q", tt.domain, certPath, tt.wantCert)
|
||||||
|
}
|
||||||
|
if keyPath != tt.wantKey {
|
||||||
|
t.Errorf("KeyPath(%q) = %q, want %q", tt.domain, keyPath, tt.wantKey)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ func ParseLine(line string) (string, bool) {
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
// dnsmasq format: address=/domain/0.0.0.0 or server=/domain/
|
// dnsmasq format: address=/domain/... or server=/domain/ or local=/domain/
|
||||||
if strings.HasPrefix(line, "address=/") || strings.HasPrefix(line, "server=/") {
|
if strings.HasPrefix(line, "address=/") || strings.HasPrefix(line, "server=/") || strings.HasPrefix(line, "local=/") {
|
||||||
parts := strings.SplitN(line, "/", 4)
|
parts := strings.SplitN(line, "/", 4)
|
||||||
if len(parts) >= 3 && parts[1] != "" {
|
if len(parts) >= 3 && parts[1] != "" {
|
||||||
return validateDomain(parts[1])
|
return validateDomain(parts[1])
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ func TestParseLine_DnsmasqFormat(t *testing.T) {
|
|||||||
{"address=/ads.example.com/0.0.0.0", "ads.example.com", true},
|
{"address=/ads.example.com/0.0.0.0", "ads.example.com", true},
|
||||||
{"address=/tracker.example.com/", "tracker.example.com", true},
|
{"address=/tracker.example.com/", "tracker.example.com", true},
|
||||||
{"server=/blocked.example.com/", "blocked.example.com", true},
|
{"server=/blocked.example.com/", "blocked.example.com", true},
|
||||||
|
{"local=/blocked.example.com/", "blocked.example.com", true},
|
||||||
{"address=//", "", false},
|
{"address=//", "", false},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
150
web/src/components/IntegrationsComponent.tsx
Normal file
150
web/src/components/IntegrationsComponent.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
web/src/router/pages/IntegrationsPage.tsx
Normal file
10
web/src/router/pages/IntegrationsPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { ErrorBoundary } from '../../components';
|
||||||
|
import { IntegrationsComponent } from '../../components/IntegrationsComponent';
|
||||||
|
|
||||||
|
export function IntegrationsPage() {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<IntegrationsComponent />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 /> },
|
||||||
|
|||||||
23
web/src/services/api/integrations.ts
Normal file
23
web/src/services/api/integrations.ts
Normal 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');
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user