mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-16 05:13:36 +00:00
Started config editor, migrated to one ssh manager for adding hosts.
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { SSHTunnelSidebar } from "@/apps/SSH/Tunnel/SSHTunnelSidebar.tsx";
|
||||
import { SSHTunnelViewer } from "@/apps/SSH/Tunnel/SSHTunnelViewer.tsx";
|
||||
import axios from "axios";
|
||||
|
||||
interface ConfigEditorProps {
|
||||
onSelectView: (view: string) => void;
|
||||
}
|
||||
|
||||
interface SSHTunnel {
|
||||
id: number;
|
||||
name: string;
|
||||
folder: string;
|
||||
sourcePort: number;
|
||||
endpointPort: number;
|
||||
sourceIP: string;
|
||||
sourceSSHPort: number;
|
||||
sourceUsername: string;
|
||||
sourcePassword: string;
|
||||
sourceAuthMethod: string;
|
||||
sourceSSHKey: string;
|
||||
sourceKeyPassword: string;
|
||||
sourceKeyType: string;
|
||||
endpointIP: string;
|
||||
endpointSSHPort: number;
|
||||
endpointUsername: string;
|
||||
endpointPassword: string;
|
||||
endpointAuthMethod: string;
|
||||
endpointSSHKey: string;
|
||||
endpointKeyPassword: string;
|
||||
endpointKeyType: string;
|
||||
maxRetries: number;
|
||||
retryInterval: number;
|
||||
connectionState: string;
|
||||
autoStart: boolean;
|
||||
isPinned: boolean;
|
||||
}
|
||||
|
||||
export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
||||
const [tunnels, setTunnels] = useState<SSHTunnel[]>([]);
|
||||
const [tunnelsLoading, setTunnelsLoading] = useState(false);
|
||||
const [tunnelsError, setTunnelsError] = useState<string | null>(null);
|
||||
const [tunnelStatusMap, setTunnelStatusMap] = useState<Record<string, any>>({});
|
||||
const sidebarRef = React.useRef<any>(null);
|
||||
|
||||
const fetchTunnels = useCallback(async () => {
|
||||
setTunnelsLoading(true);
|
||||
setTunnelsError(null);
|
||||
try {
|
||||
const jwt = document.cookie.split('; ').find(row => row.startsWith('jwt='))?.split('=')[1];
|
||||
const res = await axios.get(
|
||||
(window.location.hostname === 'localhost' ? 'http://localhost:8081' : '') + '/ssh_tunnel/tunnel',
|
||||
{ headers: { Authorization: `Bearer ${jwt}` } }
|
||||
);
|
||||
const tunnelData = res.data || [];
|
||||
setTunnels(tunnelData.map((tunnel: any) => ({
|
||||
id: tunnel.id,
|
||||
name: tunnel.name,
|
||||
folder: tunnel.folder || '',
|
||||
sourcePort: tunnel.sourcePort,
|
||||
endpointPort: tunnel.endpointPort,
|
||||
sourceIP: tunnel.sourceIP,
|
||||
sourceSSHPort: tunnel.sourceSSHPort,
|
||||
sourceUsername: tunnel.sourceUsername || '',
|
||||
sourcePassword: tunnel.sourcePassword || '',
|
||||
sourceAuthMethod: tunnel.sourceAuthMethod || 'password',
|
||||
sourceSSHKey: tunnel.sourceSSHKey || '',
|
||||
sourceKeyPassword: tunnel.sourceKeyPassword || '',
|
||||
sourceKeyType: tunnel.sourceKeyType || '',
|
||||
endpointIP: tunnel.endpointIP,
|
||||
endpointSSHPort: tunnel.endpointSSHPort,
|
||||
endpointUsername: tunnel.endpointUsername || '',
|
||||
endpointPassword: tunnel.endpointPassword || '',
|
||||
endpointAuthMethod: tunnel.endpointAuthMethod || 'password',
|
||||
endpointSSHKey: tunnel.endpointSSHKey || '',
|
||||
endpointKeyPassword: tunnel.endpointKeyPassword || '',
|
||||
endpointKeyType: tunnel.endpointKeyType || '',
|
||||
maxRetries: tunnel.maxRetries || 3,
|
||||
retryInterval: tunnel.retryInterval || 5000,
|
||||
connectionState: tunnel.connectionState || 'DISCONNECTED',
|
||||
autoStart: tunnel.autoStart || false,
|
||||
isPinned: tunnel.isPinned || false
|
||||
})));
|
||||
} catch (err: any) {
|
||||
setTunnelsError('Failed to load tunnels');
|
||||
} finally {
|
||||
setTunnelsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Poll backend for tunnel statuses
|
||||
const fetchTunnelStatuses = useCallback(async () => {
|
||||
try {
|
||||
const res = await axios.get('http://localhost:8083/status');
|
||||
setTunnelStatusMap(res.data || {});
|
||||
} catch (err) {
|
||||
// Optionally handle error
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTunnels();
|
||||
const interval = setInterval(fetchTunnels, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchTunnels]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTunnelStatuses();
|
||||
const interval = setInterval(fetchTunnelStatuses, 500);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchTunnelStatuses]);
|
||||
|
||||
// Merge backend status into tunnels
|
||||
const tunnelsWithStatus = tunnels.map(tunnel => {
|
||||
const status = tunnelStatusMap[tunnel.name] || {};
|
||||
return {
|
||||
...tunnel,
|
||||
connectionState: status.status ? status.status.toUpperCase() : tunnel.connectionState,
|
||||
statusReason: status.reason || '',
|
||||
statusErrorType: status.errorType || '',
|
||||
statusManualDisconnect: status.manualDisconnect || false,
|
||||
statusRetryCount: status.retryCount,
|
||||
statusMaxRetries: status.maxRetries,
|
||||
statusNextRetryIn: status.nextRetryIn,
|
||||
statusRetryExhausted: status.retryExhausted,
|
||||
};
|
||||
});
|
||||
|
||||
const handleConnect = async (tunnelId: string) => {
|
||||
// Immediately set to CONNECTING for instant UI feedback
|
||||
setTunnels(prev => prev.map(t =>
|
||||
t.id.toString() === tunnelId
|
||||
? { ...t, connectionState: "CONNECTING" }
|
||||
: t
|
||||
));
|
||||
const tunnel = tunnels.find(t => t.id.toString() === tunnelId);
|
||||
if (!tunnel) return;
|
||||
try {
|
||||
await axios.post('http://localhost:8083/connect', {
|
||||
...tunnel,
|
||||
name: tunnel.name
|
||||
});
|
||||
// No need to update state here; polling will update real status
|
||||
} catch (err) {
|
||||
// Optionally handle error
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async (tunnelId: string) => {
|
||||
// Immediately set to DISCONNECTING for instant UI feedback
|
||||
setTunnels(prev => prev.map(t =>
|
||||
t.id.toString() === tunnelId
|
||||
? { ...t, connectionState: "DISCONNECTING" }
|
||||
: t
|
||||
));
|
||||
const tunnel = tunnels.find(t => t.id.toString() === tunnelId);
|
||||
if (!tunnel) return;
|
||||
try {
|
||||
await axios.post('http://localhost:8083/disconnect', {
|
||||
tunnelName: tunnel.name
|
||||
});
|
||||
// No need to update state here; polling will update real status
|
||||
} catch (err) {
|
||||
// Optionally handle error
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTunnel = async (tunnelId: string) => {
|
||||
try {
|
||||
const jwt = document.cookie.split('; ').find(row => row.startsWith('jwt='))?.split('=')[1];
|
||||
await axios.delete(
|
||||
(window.location.hostname === 'localhost' ? 'http://localhost:8081' : '') + `/ssh_tunnel/tunnel/${tunnelId}`,
|
||||
{ headers: { Authorization: `Bearer ${jwt}` } }
|
||||
);
|
||||
fetchTunnels();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to delete tunnel:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditTunnel = async (tunnelId: string, data: any) => {
|
||||
try {
|
||||
const jwt = document.cookie.split('; ').find(row => row.startsWith('jwt='))?.split('=')[1];
|
||||
await axios.put(
|
||||
(window.location.hostname === 'localhost' ? 'http://localhost:8081' : '') + `/ssh_tunnel/tunnel/${tunnelId}`,
|
||||
data,
|
||||
{ headers: { Authorization: `Bearer ${jwt}` } }
|
||||
);
|
||||
fetchTunnels();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to edit tunnel:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditTunnelClick = (tunnelId: string) => {
|
||||
// Find the tunnel data and pass it to the sidebar
|
||||
const tunnel = tunnels.find(t => t.id.toString() === tunnelId);
|
||||
if (tunnel && sidebarRef.current) {
|
||||
// Call the sidebar's openEditSheet function
|
||||
sidebarRef.current.openEditSheet(tunnel);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full">
|
||||
<div className="w-64 flex-shrink-0">
|
||||
<SSHTunnelSidebar
|
||||
ref={sidebarRef}
|
||||
onSelectView={onSelectView}
|
||||
onTunnelAdded={fetchTunnels}
|
||||
onEditTunnel={handleEditTunnelClick}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<SSHTunnelViewer
|
||||
tunnels={tunnelsWithStatus}
|
||||
onConnect={handleConnect}
|
||||
onDisconnect={handleDisconnect}
|
||||
onDeleteTunnel={handleDeleteTunnel}
|
||||
onEditTunnel={handleEditTunnelClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { Loader2, Edit, Trash2 } from "lucide-react";
|
||||
|
||||
const CONNECTION_STATES = {
|
||||
DISCONNECTED: "disconnected",
|
||||
CONNECTING: "connecting",
|
||||
CONNECTED: "connected",
|
||||
VERIFYING: "verifying",
|
||||
FAILED: "failed",
|
||||
UNSTABLE: "unstable",
|
||||
RETRYING: "retrying",
|
||||
DISCONNECTING: "disconnecting"
|
||||
};
|
||||
|
||||
interface SSHTunnelObjectProps {
|
||||
hostConfig: any;
|
||||
onConnect?: () => void;
|
||||
onDisconnect?: () => void;
|
||||
onDelete?: () => void;
|
||||
onEdit?: () => void;
|
||||
connectionState?: keyof typeof CONNECTION_STATES;
|
||||
isPinned?: boolean;
|
||||
}
|
||||
|
||||
export function SSHTunnelObject({
|
||||
hostConfig = {},
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
onDelete,
|
||||
onEdit,
|
||||
connectionState = "DISCONNECTED",
|
||||
isPinned = false
|
||||
}: SSHTunnelObjectProps): React.ReactElement {
|
||||
const getStatusColor = (state: keyof typeof CONNECTION_STATES) => {
|
||||
switch (state) {
|
||||
case "CONNECTED":
|
||||
return "bg-green-500";
|
||||
case "CONNECTING":
|
||||
case "VERIFYING":
|
||||
case "RETRYING":
|
||||
return "bg-yellow-500";
|
||||
case "FAILED":
|
||||
return "bg-red-500";
|
||||
case "UNSTABLE":
|
||||
return "bg-orange-500";
|
||||
default:
|
||||
return "bg-gray-500";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (state: keyof typeof CONNECTION_STATES) => {
|
||||
switch (state) {
|
||||
case "CONNECTED":
|
||||
return "Connected";
|
||||
case "CONNECTING":
|
||||
return "Connecting";
|
||||
case "VERIFYING":
|
||||
return "Verifying";
|
||||
case "FAILED":
|
||||
return "Failed";
|
||||
case "UNSTABLE":
|
||||
return "Unstable";
|
||||
case "RETRYING":
|
||||
return "Retrying";
|
||||
default:
|
||||
return "Disconnected";
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const isConnected = connectionState === "CONNECTED";
|
||||
const isConnecting = ["CONNECTING", "VERIFYING", "RETRYING"].includes(connectionState);
|
||||
const isDisconnecting = connectionState === "DISCONNECTING";
|
||||
|
||||
return (
|
||||
<Card className="w-full bg-card border-border shadow-sm hover:shadow-md transition-shadow relative group p-0">
|
||||
{/* Hover overlay buttons */}
|
||||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-10 flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-8 w-8 p-0 bg-black/50 hover:bg-black/70 border-0"
|
||||
onClick={onEdit}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="h-8 w-8 p-0 bg-red-500/50 hover:bg-red-500/70 border-0"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-2">
|
||||
<div className="flex items-center justify-between gap-1 mb-1">
|
||||
<div className="text-lg font-semibold text-card-foreground flex-1 min-w-0">
|
||||
<span className="break-words">
|
||||
{isPinned && <span className="text-yellow-400 mr-1 flex-shrink-0">★</span>}
|
||||
{hostConfig.name || "My SSH Tunnel"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-px h-4 bg-border mx-1"></div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className={`w-2 h-2 rounded-full ${getStatusColor(connectionState)}`} />
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{getStatusText(connectionState)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="mb-1" />
|
||||
<div className="space-y-1 mb-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground flex-shrink-0 mr-2">Source:</span>
|
||||
<span className="text-card-foreground font-mono text-right break-all">
|
||||
{hostConfig.source || "localhost:22"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground flex-shrink-0 mr-2">Endpoint:</span>
|
||||
<span className="text-card-foreground font-mono text-right break-all">
|
||||
{hostConfig.endpoint || "test:224"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-1" />
|
||||
{/* Error/Status Reason */}
|
||||
{((connectionState === "FAILED" || connectionState === "UNSTABLE") && hostConfig.statusReason) && (
|
||||
<div className="mb-2 text-xs text-red-500 bg-red-500/10 rounded px-2 py-1 border border-red-500/20">
|
||||
{hostConfig.statusReason}
|
||||
{typeof hostConfig.statusReason === 'string' && hostConfig.statusReason.includes('Max retries exhausted') && (
|
||||
<>
|
||||
<br />
|
||||
<span>
|
||||
Check your Docker logs for the error reason, join the <a href="https://discord.com/invite/jVQGdvHDrf" target="_blank" rel="noopener noreferrer" className="underline text-blue-400">Discord</a> or create a <a href="https://github.com/LukeGus/Termix/issues/new" target="_blank" rel="noopener noreferrer" className="underline text-blue-400">GitHub issue</a> for help.
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Button
|
||||
onClick={onConnect}
|
||||
disabled={isConnected || isConnecting || isDisconnecting}
|
||||
className="flex-1"
|
||||
variant={isConnected ? "secondary" : "default"}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : isConnected ? (
|
||||
"Connected"
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onDisconnect}
|
||||
disabled={!isConnected || isDisconnecting || isConnecting}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
>
|
||||
{isDisconnecting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Disconnecting...
|
||||
</>
|
||||
) : (
|
||||
"Disconnect"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,137 @@
|
||||
import React from "react";
|
||||
import { SSHTunnelObject } from "./SSHTunnelObject.tsx";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
|
||||
interface SSHTunnelViewerProps {
|
||||
tunnels: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
folder: string;
|
||||
sourcePort: number;
|
||||
endpointPort: number;
|
||||
sourceIP: string;
|
||||
sourceSSHPort: number;
|
||||
sourceUsername: string;
|
||||
sourcePassword: string;
|
||||
sourceAuthMethod: string;
|
||||
sourceSSHKey: string;
|
||||
sourceKeyPassword: string;
|
||||
sourceKeyType: string;
|
||||
endpointIP: string;
|
||||
endpointSSHPort: number;
|
||||
endpointUsername: string;
|
||||
endpointPassword: string;
|
||||
endpointAuthMethod: string;
|
||||
endpointSSHKey: string;
|
||||
endpointKeyPassword: string;
|
||||
endpointKeyType: string;
|
||||
maxRetries: number;
|
||||
retryInterval: number;
|
||||
connectionState?: string;
|
||||
autoStart: boolean;
|
||||
isPinned: boolean;
|
||||
}>;
|
||||
onConnect?: (tunnelId: string) => void;
|
||||
onDisconnect?: (tunnelId: string) => void;
|
||||
onDeleteTunnel?: (tunnelId: string) => void;
|
||||
onEditTunnel?: (tunnelId: string) => void;
|
||||
}
|
||||
|
||||
export function SSHTunnelViewer({
|
||||
tunnels = [],
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
onDeleteTunnel,
|
||||
onEditTunnel
|
||||
}: SSHTunnelViewerProps): React.ReactElement {
|
||||
const handleConnect = (tunnelId: string) => {
|
||||
onConnect?.(tunnelId);
|
||||
};
|
||||
|
||||
const handleDisconnect = (tunnelId: string) => {
|
||||
onDisconnect?.(tunnelId);
|
||||
};
|
||||
|
||||
// Group tunnels by folder and sort
|
||||
const tunnelsByFolder = React.useMemo(() => {
|
||||
const map: Record<string, typeof tunnels> = {};
|
||||
tunnels.forEach(tunnel => {
|
||||
const folder = tunnel.folder && tunnel.folder.trim() ? tunnel.folder : 'No Folder';
|
||||
if (!map[folder]) map[folder] = [];
|
||||
map[folder].push(tunnel);
|
||||
});
|
||||
return map;
|
||||
}, [tunnels]);
|
||||
|
||||
const sortedFolders = React.useMemo(() => {
|
||||
const folders = Object.keys(tunnelsByFolder);
|
||||
folders.sort((a, b) => {
|
||||
if (a === 'No Folder') return -1;
|
||||
if (b === 'No Folder') return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
return folders;
|
||||
}, [tunnelsByFolder]);
|
||||
|
||||
const getSortedTunnels = (arr: typeof tunnels) => {
|
||||
const pinned = arr.filter(t => t.isPinned).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
const rest = arr.filter(t => !t.isPinned).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
return [...pinned, ...rest];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full p-6" style={{ width: 'calc(100vw - 256px)', maxWidth: 'none' }}>
|
||||
<div className="w-full min-w-0" style={{ width: '100%', maxWidth: 'none' }}>
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-foreground mb-2">
|
||||
SSH Tunnels
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your SSH tunnel connections
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Accordion Layout */}
|
||||
{tunnels.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
No SSH Tunnels
|
||||
</h3>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
Create your first SSH tunnel to get started. Use the sidebar to add a new tunnel configuration.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Accordion type="multiple" className="w-full" defaultValue={sortedFolders}>
|
||||
{sortedFolders.map((folder, idx) => (
|
||||
<AccordionItem value={folder} key={`folder-${folder}`} className={idx === 0 ? "mt-0" : "mt-2"}>
|
||||
<AccordionTrigger className="text-base font-semibold rounded-t-none px-3 py-2" style={{marginTop: idx === 0 ? 0 : undefined}}>
|
||||
{folder}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col gap-1 px-3 pb-2 pt-1">
|
||||
<div className="grid grid-cols-4 gap-6 w-full">
|
||||
{getSortedTunnels(tunnelsByFolder[folder]).map((tunnel, tunnelIndex) => (
|
||||
<div key={tunnel.id} className="w-full">
|
||||
<SSHTunnelObject
|
||||
hostConfig={tunnel}
|
||||
connectionState={tunnel.connectionState as any}
|
||||
isPinned={tunnel.isPinned}
|
||||
onConnect={() => handleConnect(tunnel.id.toString())}
|
||||
onDisconnect={() => handleDisconnect(tunnel.id.toString())}
|
||||
onDelete={() => onDeleteTunnel?.(tunnel.id.toString())}
|
||||
onEdit={() => onEditTunnel?.(tunnel.id.toString())}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user