mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-02 05:08:48 +02:00
[eric] fix API key priority over 9Router, fix OAuth callback 500, polish Settings UI
This commit is contained in:
@@ -5,6 +5,8 @@ const nextConfig = {
|
||||
unoptimized: true
|
||||
},
|
||||
env: {},
|
||||
// Allow builds with both Turbopack (Next 16 default) and webpack
|
||||
turbopack: {},
|
||||
webpack: (config, { isServer }) => {
|
||||
// Ignore fs/path modules in browser bundle
|
||||
if (!isServer) {
|
||||
|
||||
@@ -862,17 +862,17 @@ class AgentManager:
|
||||
"disallowed_tools": effective_disallowed,
|
||||
"include_partial_messages": True,
|
||||
}
|
||||
# Priority: 9Router subscription → API key
|
||||
# Priority: API key → 9Router subscription
|
||||
from backend.apps.nine_router import is_running as _9r_running
|
||||
if _9r_running():
|
||||
if global_settings.anthropic_api_key:
|
||||
options_kwargs["env"] = {"ANTHROPIC_API_KEY": global_settings.anthropic_api_key}
|
||||
elif _9r_running():
|
||||
options_kwargs["env"] = {
|
||||
"ANTHROPIC_API_KEY": "9router",
|
||||
"ANTHROPIC_BASE_URL": "http://localhost:20128",
|
||||
}
|
||||
# --bare skips CLI's own OAuth/keychain auth, uses only ANTHROPIC_API_KEY
|
||||
options_kwargs["extra_args"] = {"bare": None}
|
||||
elif global_settings.anthropic_api_key:
|
||||
options_kwargs["env"] = {"ANTHROPIC_API_KEY": global_settings.anthropic_api_key}
|
||||
else:
|
||||
raise ValueError("No AI provider configured. Set an API key or connect a subscription.")
|
||||
if mcp_servers:
|
||||
|
||||
@@ -114,6 +114,7 @@ def create_provider(
|
||||
auth_token=getattr(settings, "openswarm_auth_token", None),
|
||||
base_url=getattr(settings, "openswarm_proxy_url", None) or "https://api.openswarm.ai",
|
||||
)
|
||||
# Priority: API key → 9Router subscription
|
||||
if settings.anthropic_api_key:
|
||||
return AnthropicProvider(api_key=settings.anthropic_api_key)
|
||||
# No API key — try 9Router as fallback
|
||||
|
||||
@@ -15,7 +15,7 @@ from backend.apps.analytics.collector import init as init_collector, shutdown as
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_VERSION = "1.0.16"
|
||||
APP_VERSION = "1.0.17"
|
||||
|
||||
_heartbeat_task: asyncio.Task | None = None
|
||||
|
||||
|
||||
@@ -76,11 +76,32 @@ def _find_node() -> str | None:
|
||||
async def ensure_running():
|
||||
"""Start 9Router if not already running."""
|
||||
global _process
|
||||
if is_running():
|
||||
logger.info("9Router already running on port %d", NINE_ROUTER_PORT)
|
||||
return
|
||||
|
||||
_is_packaged = os.environ.get("OPENSWARM_PACKAGED") == "1"
|
||||
|
||||
if is_running():
|
||||
# In dev mode, kill stale standalone servers (from previous builds)
|
||||
# so we can start `next dev` which always uses latest source code
|
||||
if not _is_packaged:
|
||||
import subprocess as _sp
|
||||
try:
|
||||
result = _sp.run(
|
||||
["pgrep", "-f", "next-server"],
|
||||
capture_output=True, text=True, timeout=3,
|
||||
)
|
||||
if result.stdout.strip():
|
||||
logger.info("Dev mode: killing stale standalone 9Router to use next dev instead")
|
||||
_sp.run(["pkill", "-f", "next-server"], timeout=5)
|
||||
import asyncio
|
||||
await asyncio.sleep(2)
|
||||
else:
|
||||
logger.info("9Router already running on port %d", NINE_ROUTER_PORT)
|
||||
return
|
||||
except Exception:
|
||||
logger.info("9Router already running on port %d", NINE_ROUTER_PORT)
|
||||
return
|
||||
else:
|
||||
logger.info("9Router already running on port %d", NINE_ROUTER_PORT)
|
||||
return
|
||||
_9router_dir = _find_9router_dir()
|
||||
|
||||
if _is_packaged and _9router_dir:
|
||||
|
||||
@@ -77,9 +77,10 @@ def validate_credentials(settings: AppSettings, provider: str = "anthropic") ->
|
||||
|
||||
def get_provider_credentials(settings: AppSettings, provider: str) -> dict[str, str]:
|
||||
"""Return credential dict for a specific provider."""
|
||||
p = provider.lower().strip()
|
||||
validate_credentials(settings, provider)
|
||||
|
||||
if provider == "anthropic":
|
||||
if p in ("anthropic", "claude"):
|
||||
if getattr(settings, "connection_mode", "own_key") == "managed":
|
||||
return {
|
||||
"auth_token": getattr(settings, "openswarm_auth_token", "") or "",
|
||||
@@ -87,18 +88,18 @@ def get_provider_credentials(settings: AppSettings, provider: str) -> dict[str,
|
||||
}
|
||||
return {"api_key": settings.anthropic_api_key or ""}
|
||||
|
||||
if provider == "openai":
|
||||
if p in ("openai", "codex"):
|
||||
return {"api_key": settings.openai_api_key or ""}
|
||||
|
||||
if provider == "gemini":
|
||||
if p in ("gemini", "google", "gemini-cli"):
|
||||
return {"api_key": getattr(settings, "google_api_key", "") or ""}
|
||||
|
||||
if provider == "openrouter":
|
||||
if p == "openrouter":
|
||||
return {"api_key": getattr(settings, "openrouter_api_key", "") or ""}
|
||||
|
||||
# Custom provider
|
||||
for cp in getattr(settings, "custom_providers", []):
|
||||
if cp.name == provider:
|
||||
if cp.name.lower() == p:
|
||||
return {"api_key": cp.api_key, "base_url": cp.base_url}
|
||||
|
||||
raise ValueError(f"No credentials for provider: {provider}")
|
||||
@@ -139,15 +140,15 @@ def get_anthropic_client(settings: AppSettings) -> anthropic.AsyncAnthropic:
|
||||
base_url=proxy_url,
|
||||
)
|
||||
|
||||
# Prefer 9Router subscription (free for users with Claude/ChatGPT/Gemini subscriptions)
|
||||
# Prefer API key when set
|
||||
if settings.anthropic_api_key:
|
||||
return anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||
|
||||
# Fall back to 9Router subscription (free for users with Claude/ChatGPT/Gemini subscriptions)
|
||||
if _check_9router():
|
||||
return anthropic.AsyncAnthropic(
|
||||
api_key="9router",
|
||||
base_url="http://localhost:20128",
|
||||
)
|
||||
|
||||
# Fall back to API key
|
||||
if settings.anthropic_api_key:
|
||||
return anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||
|
||||
raise ValueError("No AI provider configured. Set an API key or connect a subscription.")
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openswarm",
|
||||
"version": "1.0.15",
|
||||
"version": "1.0.16",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openswarm",
|
||||
"version": "1.0.15",
|
||||
"version": "1.0.16",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.3.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openswarm",
|
||||
"version": "1.0.16",
|
||||
"version": "1.0.17",
|
||||
"description": "OpenSwarm — AI Agent Orchestrator",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -164,17 +164,36 @@ const SUBSCRIPTION_PROVIDERS = [
|
||||
{ id: 'github', name: 'GitHub Copilot', desc: 'Claude + GPT models via your Copilot subscription', color: '#8B949E', preview: true },
|
||||
];
|
||||
|
||||
const SubscriptionCard: React.FC<{ provider: typeof SUBSCRIPTION_PROVIDERS[0]; connected: boolean; onConnect: () => void; onDisconnect: () => void; connecting: boolean; userCode?: string }> = ({ provider, connected, onConnect, onDisconnect, connecting, userCode }) => {
|
||||
const SubscriptionCard: React.FC<{ provider: typeof SUBSCRIPTION_PROVIDERS[0]; connected: boolean; onConnect: () => void; onDisconnect: () => void; connecting: boolean; userCode?: string; disconnecting?: boolean }> = ({ provider, connected, onConnect, onDisconnect, connecting, userCode, disconnecting }) => {
|
||||
const c = useClaudeTokens();
|
||||
const isPreview = (provider as any).preview;
|
||||
return (
|
||||
<Box sx={{ p: 1.5, borderRadius: `${c.radius.md}px`, border: `1px solid ${connected ? c.status.success + '30' : c.border.subtle}`, bgcolor: connected ? `${c.status.success}04` : 'transparent', opacity: isPreview ? 0.5 : 1 }}>
|
||||
<Box sx={{
|
||||
p: 1.5, borderRadius: `${c.radius.md}px`,
|
||||
border: `1px solid ${connected ? c.status.success + '30' : connecting ? c.accent.primary + '30' : c.border.subtle}`,
|
||||
bgcolor: connected ? `${c.status.success}04` : connecting ? `${c.accent.primary}04` : 'transparent',
|
||||
opacity: isPreview ? 0.5 : 1,
|
||||
transition: 'all 0.3s ease',
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: connected ? c.status.success : c.border.medium, flexShrink: 0 }} />
|
||||
<Box sx={{
|
||||
width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
|
||||
bgcolor: connected ? c.status.success : connecting ? c.accent.primary : c.border.medium,
|
||||
transition: 'background-color 0.3s ease',
|
||||
...(connecting ? {
|
||||
animation: 'pulse-dot 1.5s ease-in-out infinite',
|
||||
'@keyframes pulse-dot': {
|
||||
'0%, 100%': { opacity: 1, transform: 'scale(1)' },
|
||||
'50%': { opacity: 0.4, transform: 'scale(0.8)' },
|
||||
},
|
||||
} : {}),
|
||||
}} />
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '0.78rem', fontWeight: 600, color: c.text.primary }}>{provider.name}</Typography>
|
||||
<Typography sx={{ fontSize: '0.65rem', color: c.text.muted }}>{provider.desc}</Typography>
|
||||
<Typography sx={{ fontSize: '0.65rem', color: connecting ? c.accent.primary : c.text.muted, transition: 'color 0.3s ease' }}>
|
||||
{connecting ? 'Waiting for authorization...' : provider.desc}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
{isPreview ? (
|
||||
@@ -182,17 +201,26 @@ const SubscriptionCard: React.FC<{ provider: typeof SUBSCRIPTION_PROVIDERS[0]; c
|
||||
Coming soon
|
||||
</Typography>
|
||||
) : connected ? (
|
||||
<Typography onClick={onDisconnect} sx={{ fontSize: '0.68rem', color: c.text.tertiary, cursor: 'pointer', '&:hover': { color: c.status.error } }}>
|
||||
Disconnect
|
||||
</Typography>
|
||||
disconnecting ? (
|
||||
<CircularProgress size={14} sx={{ color: c.text.ghost }} />
|
||||
) : (
|
||||
<Typography onClick={onDisconnect} sx={{ fontSize: '0.68rem', color: c.text.tertiary, cursor: 'pointer', '&:hover': { color: c.status.error }, transition: 'color 0.2s ease' }}>
|
||||
Disconnect
|
||||
</Typography>
|
||||
)
|
||||
) : connecting && userCode ? (
|
||||
<Box sx={{ textAlign: 'right' }}>
|
||||
<Typography sx={{ fontSize: '0.68rem', color: c.text.muted }}>Enter code:</Typography>
|
||||
<Typography sx={{ fontSize: '0.85rem', fontWeight: 700, color: c.accent.primary, fontFamily: 'monospace', letterSpacing: '0.1em' }}>{userCode}</Typography>
|
||||
</Box>
|
||||
) : connecting ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.8 }}>
|
||||
<CircularProgress size={14} sx={{ color: c.accent.primary }} />
|
||||
<Typography sx={{ fontSize: '0.68rem', color: c.accent.primary }}>Connecting...</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Button onClick={onConnect} disabled={connecting} variant="outlined" size="small" sx={{ textTransform: 'none', fontSize: '0.7rem', color: c.text.primary, borderColor: c.border.medium, minWidth: 70, '&:hover': { borderColor: c.accent.primary } }}>
|
||||
{connecting ? 'Waiting...' : 'Connect'}
|
||||
<Button onClick={onConnect} variant="outlined" size="small" sx={{ textTransform: 'none', fontSize: '0.7rem', color: c.text.primary, borderColor: c.border.medium, minWidth: 70, '&:hover': { borderColor: c.accent.primary }, transition: 'all 0.2s ease' }}>
|
||||
Connect
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
@@ -204,6 +232,7 @@ const SubscriptionCards: React.FC = () => {
|
||||
const c = useClaudeTokens();
|
||||
const [status, setStatus] = useState<any>(null);
|
||||
const [connecting, setConnecting] = useState<string | null>(null);
|
||||
const [disconnecting, setDisconnecting] = useState<string | null>(null);
|
||||
const [userCode, setUserCode] = useState('');
|
||||
const [pollTimer, setPollTimer] = useState<any>(null);
|
||||
|
||||
@@ -323,6 +352,7 @@ const SubscriptionCards: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleDisconnect = async (providerId: string) => {
|
||||
setDisconnecting(providerId);
|
||||
try {
|
||||
await fetch(`${API_BASE}/agents/subscriptions/disconnect`, {
|
||||
method: 'POST',
|
||||
@@ -331,13 +361,36 @@ const SubscriptionCards: React.FC = () => {
|
||||
});
|
||||
} catch {}
|
||||
// Wait briefly for 9Router to process, then refresh
|
||||
setTimeout(fetchStatus, 500);
|
||||
setTimeout(() => { fetchStatus(); setDisconnecting(null); }, 500);
|
||||
};
|
||||
|
||||
if (!status) {
|
||||
// Initial loading — show skeleton cards
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{SUBSCRIPTION_PROVIDERS.map(p => (
|
||||
<Box key={p.id} sx={{
|
||||
p: 1.5, borderRadius: `${c.radius.md}px`, border: `1px solid ${c.border.subtle}`,
|
||||
display: 'flex', alignItems: 'center', gap: 1,
|
||||
animation: 'skeleton-pulse 1.5s ease-in-out infinite',
|
||||
'@keyframes skeleton-pulse': { '0%, 100%': { opacity: 0.5 }, '50%': { opacity: 0.25 } },
|
||||
}}>
|
||||
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: c.border.medium, flexShrink: 0 }} />
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box sx={{ width: 100, height: 12, bgcolor: c.border.subtle, borderRadius: 1, mb: 0.5 }} />
|
||||
<Box sx={{ width: 180, height: 10, bgcolor: c.border.subtle, borderRadius: 1 }} />
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status?.running) {
|
||||
return (
|
||||
<Box sx={{ p: 2, borderRadius: `${c.radius.md}px`, border: `1px solid ${c.border.subtle}`, textAlign: 'center' }}>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mb: 1 }}>
|
||||
<CircularProgress size={18} sx={{ color: c.text.ghost, mb: 1 }} />
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mb: 0.5 }}>
|
||||
Starting subscription service...
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.65rem', color: c.text.ghost }}>
|
||||
@@ -357,6 +410,7 @@ const SubscriptionCards: React.FC = () => {
|
||||
onConnect={() => handleConnect(p.id)}
|
||||
onDisconnect={() => handleDisconnect(p.id)}
|
||||
connecting={connecting === p.id}
|
||||
disconnecting={disconnecting === p.id}
|
||||
userCode={connecting === p.id ? userCode : undefined}
|
||||
/>
|
||||
))}
|
||||
@@ -401,7 +455,59 @@ const UsageStats: React.FC = () => {
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
if (!stats) return null;
|
||||
if (!stats) {
|
||||
// Skeleton loading state
|
||||
const skeletonPulse = {
|
||||
animation: 'skeleton-pulse 1.5s ease-in-out infinite',
|
||||
'@keyframes skeleton-pulse': { '0%, 100%': { opacity: 0.5 }, '50%': { opacity: 0.25 } },
|
||||
};
|
||||
const skeletonCard = {
|
||||
p: 1.5, borderRadius: `${c.radius.md}px`, bgcolor: c.bg.elevated,
|
||||
border: `1px solid ${c.border.subtle}`, ...skeletonPulse,
|
||||
};
|
||||
return (
|
||||
<Box sx={{ mb: 2.5 }}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 1, mb: 1 }}>
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<Box key={i} sx={skeletonCard}>
|
||||
<Box sx={{ width: 60, height: 8, bgcolor: c.border.subtle, borderRadius: 1, mb: 1 }} />
|
||||
<Box sx={{ width: 50, height: 18, bgcolor: c.border.subtle, borderRadius: 1, mb: 0.5 }} />
|
||||
<Box sx={{ width: 90, height: 8, bgcolor: c.border.subtle, borderRadius: 1 }} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 1, mb: 1.5 }}>
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<Box key={i} sx={skeletonCard}>
|
||||
<Box sx={{ width: 70, height: 8, bgcolor: c.border.subtle, borderRadius: 1, mb: 1 }} />
|
||||
<Box sx={{ width: 45, height: 18, bgcolor: c.border.subtle, borderRadius: 1, mb: 0.5 }} />
|
||||
<Box sx={{ width: 80, height: 8, bgcolor: c.border.subtle, borderRadius: 1 }} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
|
||||
{Array.from({ length: 2 }, (_, i) => (
|
||||
<Box key={i} sx={{ ...skeletonCard, p: 2 }}>
|
||||
<Box sx={{ width: 80, height: 8, bgcolor: c.border.subtle, borderRadius: 1, mb: 2 }} />
|
||||
{Array.from({ length: 3 }, (_, j) => (
|
||||
<Box key={j} sx={{ mb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Box sx={{ width: 60 + j * 15, height: 10, bgcolor: c.border.subtle, borderRadius: 1 }} />
|
||||
<Box sx={{ width: 35, height: 10, bgcolor: c.border.subtle, borderRadius: 1 }} />
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: '1px' }}>
|
||||
{Array.from({ length: 16 }, (_, k) => (
|
||||
<Box key={k} sx={{ width: 5, height: 5, bgcolor: c.border.subtle, opacity: k < 8 - j * 2 ? 0.6 : 0.2 }} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const formatCost = (v: number) => {
|
||||
if (v === 0) return '$0.00';
|
||||
@@ -757,11 +863,12 @@ const Settings: React.FC = () => {
|
||||
PaperProps={{
|
||||
sx: {
|
||||
width: 780,
|
||||
maxHeight: '85vh',
|
||||
height: '85vh',
|
||||
bgcolor: c.bg.page,
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
boxShadow: c.shadow.md,
|
||||
transition: 'none',
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -814,7 +921,7 @@ const Settings: React.FC = () => {
|
||||
scrollbarColor: `${c.border.medium} transparent`,
|
||||
}}>
|
||||
{activeTab === 'general' ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
|
||||
|
||||
{/* ── Agent Defaults ── */}
|
||||
<Typography sx={sectionSx}>Agent Defaults</Typography>
|
||||
@@ -1283,7 +1390,7 @@ const Settings: React.FC = () => {
|
||||
|
||||
</Box>
|
||||
) : activeTab === 'models' ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1, gap: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1, gap: 2.5, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
|
||||
|
||||
{/* ── USE EXISTING SUBSCRIPTIONS ── */}
|
||||
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600 }}>
|
||||
@@ -1347,11 +1454,11 @@ const Settings: React.FC = () => {
|
||||
|
||||
</Box>
|
||||
) : activeTab === 'usage' ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
|
||||
<UsageStats />
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ pt: 2.5, pb: 1 }}>
|
||||
<Box sx={{ pt: 2.5, pb: 1, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
|
||||
<CommandsContent />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user