755 lines
39 KiB
TypeScript
755 lines
39 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react'
|
||||
|
|
import * as api from './api'
|
|||
|
|
import type { DashboardData, ServiceInfo, Account } from './api'
|
|||
|
||||
/* ── Types ──────────────────────────────────────────────────────────────── */
|
||||
|
|
type Page =
|
|||
|
|
| 'dashboard' | 'accounts' | 'createAccount' | 'listAccounts'
|
|||
|
|
| 'domains' | 'dns' | 'databases' | 'email'
|
|||
|
|
| 'ssl' | 'backups' | 'firewall'
|
|||
|
|
| 'apache' | 'php' | 'services'
|
|||
|
|
| 'settings' | 'logs' | 'packages'
|
|||
|
||||
/* ── Nav ─────────────────────────────────────────────────────────────────── */
|
||||
type NavGroup = { label: string; items: { id: Page; label: string; icon: string }[] }
|
||||
|
||||
const NAV_GROUPS: NavGroup[] = [
|
||||
{ label: 'Overview', items: [{ id: 'dashboard', label: 'Dashboard', icon: '▦' }] },
|
||||
|
|
{ label: 'Account Management',items: [
|
|||
|
|
{ id: 'listAccounts', label: 'List Accounts', icon: '📋' },
|
|||
|
|
{ id: 'createAccount',label: 'Create Account', icon: '➕' },
|
|||
|
|
{ id: 'packages', label: 'Feature Packages', icon: '📦' },
|
|||
|
|
]},
|
|||
|
|
{ label: 'Domains & DNS', items: [
|
|||
|
|
{ id: 'domains', label: 'Zone Manager', icon: '🌐' },
|
|||
|
|
{ id: 'dns', label: 'DNS Records', icon: '🔗' },
|
|||
|
|
]},
|
|||
|
|
{ label: 'Databases', items: [{ id: 'databases', label: 'MySQL / MariaDB', icon: '🗄️' }] },
|
|||
|
|
{ label: 'Email', items: [{ id: 'email', label: 'Email Routing', icon: '✉️' }] },
|
|||
|
|
{ label: 'Security', items: [
|
|||
|
|
{ id: 'ssl', label: 'SSL / TLS', icon: '🔒' },
|
|||
|
|
{ id: 'firewall', label: 'Firewall (CSF)', icon: '🛡️' },
|
|||
|
|
]},
|
|||
|
|
{ label: 'Server', items: [
|
|||
|
|
{ id: 'apache', label: 'Apache Config', icon: '⚙️' },
|
|||
|
|
{ id: 'php', label: 'PHP Manager', icon: '🐘' },
|
|||
|
|
{ id: 'services', label: 'Service Manager', icon: '🔧' },
|
|||
|
|
{ id: 'backups', label: 'Backup Manager', icon: '💾' },
|
|||
|
|
{ id: 'logs', label: 'System Logs', icon: '📜' },
|
|||
|
|
{ id: 'settings', label: 'Server Config', icon: '🔩' },
|
|||
|
|
]},
|
|||
]
|
||||
|
||||
/* ── Sidebar ────────────────────────────────────────────────────────────── */
|
||||
function Sidebar({ current, onNav, onLogout, collapsed, onToggle, username }: {
|
||||
current: Page; onNav: (p: Page) => void; onLogout: () => void
|
||||
collapsed: boolean; onToggle: () => void; username: string
|
||||
}) {
|
||||
|
|
return (
|
|||
<aside className={`flex-shrink-0 bg-[#1a1f2e] border-r border-[#2a3044] flex flex-col min-h-screen transition-all duration-200 ${collapsed ? 'w-14' : 'w-56'}`}>
|
||||
<div className="h-14 flex items-center justify-between px-3 border-b border-[#2a3044]">
|
||||
|
|
{!collapsed && (
|
|||
|
|
<span className="text-base font-black tracking-tight text-white">
|
|||
|
|
Nix<span className="text-orange-400">Server</span>
|
|||
|
|
<span className="ml-1.5 text-[9px] font-bold bg-orange-500 text-white rounded px-1 py-0.5 uppercase tracking-wide align-middle">WHM</span>
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
<button onClick={onToggle} className="text-gray-500 hover:text-white p-1 rounded transition-colors ml-auto" title={collapsed ? 'Expand' : 'Collapse'}>
|
||||
{collapsed ? '▶' : '◀'}
|
||||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
<nav className="flex-1 overflow-y-auto py-2">
|
||||
{NAV_GROUPS.map(group => (
|
||||
|
|
<div key={group.label} className="mb-1">
|
|||
|
|
{!collapsed && (
|
|||
<p className="px-3 pt-3 pb-1 text-[9px] font-bold text-gray-600 uppercase tracking-widest">{group.label}</p>
|
||||
)}
|
||||
|
|
{group.items.map(item => (
|
|||
|
|
<button
|
|||
|
|
key={item.id}
|
|||
|
|
onClick={() => onNav(item.id)}
|
|||
|
|
title={collapsed ? item.label : undefined}
|
|||
|
|
className={`w-full flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-all ${
|
|||
|
|
current === item.id
|
|||
|
|
? 'bg-orange-500/20 text-orange-400 border-r-2 border-orange-400'
|
|||
|
|
: 'text-gray-400 hover:bg-[#252b3d] hover:text-white'
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
<span className="text-sm w-4 text-center flex-shrink-0">{item.icon}</span>
|
|||
|
|
{!collapsed && <span className="truncate">{item.label}</span>}
|
|||
|
|
</button>
|
|||
))}
|
||||
|
|
</div>
|
|||
))}
|
||||
|
|
</nav>
|
|||
|
||||
<div className="border-t border-[#2a3044] p-2">
|
||||
|
|
{!collapsed && (
|
|||
|
|
<div className="flex items-center gap-2 px-1 py-1.5 mb-1">
|
|||
<div className="w-7 h-7 rounded-full bg-orange-500 flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
|
||||
|
|
{username.charAt(0).toUpperCase()}
|
|||
|
|
</div>
|
|||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold text-white truncate">{username}</p>
|
||||
<p className="text-[10px] text-gray-500">root</p>
|
||||
|
|
</div>
|
|||
</div>
|
||||
)}
|
||||
<button onClick={onLogout} title="Sign out"
|
||||
|
|
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs text-gray-500 hover:bg-[#252b3d] hover:text-red-400 transition-colors ${collapsed ? 'justify-center' : ''}`}
|
|||
>
|
||||
|
|
<span>⏻</span>
|
|||
|
|
{!collapsed && 'Sign out'}
|
|||
|
|
</button>
|
|||
</div>
|
||||
</aside>
|
||||
|
|
)
|
|||
|
|
}
|
|||
|
||||
/* ── Stat bar ────────────────────────────────────────────────────────────── */
|
||||
|
|
function StatBar({ data }: { data: DashboardData | null }) {
|
|||
|
|
const s = data?.stats
|
|||
|
|
const si = data?.sysinfo
|
|||
|
|
|
|||
|
|
const ramPct = si && si.ram_total_mb > 0
|
|||
|
|
? Math.round((si.ram_used_mb / si.ram_total_mb) * 100)
|
|||
|
|
: null
|
|||
|
|
const diskPct = si && si.disk_total_gb > 0
|
|||
|
|
? Math.round((si.disk_used_gb / si.disk_total_gb) * 100)
|
|||
|
|
: null
|
|||
|
|
|
|||
|
|
function barColor(pct: number) {
|
|||
|
|
return pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-yellow-500' : 'bg-emerald-500'
|
|||
|
|
}
|
|||
|
|
|
|||
const stats = [
|
||||
{ label: 'Hosting Accounts', value: s?.accounts ?? '—', unit: '', color: 'text-orange-400', bar: null },
|
||||
|
|
{ label: 'Domains', value: s?.domains ?? '—', unit: '', color: 'text-blue-400', bar: null },
|
|||
|
|
{ label: 'Databases', value: s?.databases ?? '—', unit: '', color: 'text-purple-400', bar: null },
|
|||
|
|
{ label: 'Email Accounts', value: s?.email_accounts ?? '—', unit: '', color: 'text-cyan-400', bar: null },
|
|||
|
|
{ label: 'Disk Used', value: si ? `${si.disk_used_gb}` : '—', unit: 'GB', color: diskPct && diskPct > 80 ? 'text-red-400' : 'text-emerald-400', bar: diskPct },
|
|||
|
|
{ label: 'RAM Used', value: si ? `${Math.round(si.ram_used_mb / 1024 * 10) / 10}` : '—', unit: 'GB', color: ramPct && ramPct > 80 ? 'text-red-400' : 'text-yellow-400', bar: ramPct },
|
|||
]
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 lg:grid-cols-6 gap-0 border border-[#2a3044] rounded-lg overflow-hidden">
|
||||
|
|
{stats.map((s, i) => (
|
|||
<div key={s.label} className={`bg-[#1a1f2e] px-4 py-3 ${i < stats.length - 1 ? 'border-r border-[#2a3044]' : ''}`}>
|
||||
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider">{s.label}</p>
|
||||
<p className={`text-xl font-black mt-0.5 ${s.color}`}>
|
||||
|
|
{s.value}<span className="text-xs font-normal text-gray-500 ml-1">{s.unit}</span>
|
|||
|
|
</p>
|
|||
|
|
{s.bar !== null && (
|
|||
|
|
<div className="mt-1.5 h-1 bg-[#2a3044] rounded-full overflow-hidden">
|
|||
|
|
<div className={`h-full rounded-full ${barColor(s.bar)}`} style={{ width: `${s.bar}%` }} />
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
</div>
|
||||
))}
|
||||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
||||
/* ── Service table ───────────────────────────────────────────────────────── */
|
||||
|
|
function ServiceTable({ services, loading, onAction }: {
|
|||
|
|
services: ServiceInfo[]
|
|||
|
|
loading: boolean
|
|||
|
|
onAction: (name: string, action: 'start' | 'stop' | 'restart') => void
|
|||
|
|
}) {
|
|||
|
|
const badge: Record<string, string> = {
|
|||
running: 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30',
|
||||
|
|
stopped: 'bg-red-500/20 text-red-400 border border-red-500/30',
|
|||
|
|
warning: 'bg-yellow-500/20 text-yellow-400 border border-yellow-500/30',
|
|||
|
|
}
|
|||
const running = services.filter(s => s.status === 'running').length
|
||||
|
|
|
|||
return (
|
||||
|
|
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-lg overflow-hidden">
|
|||
|
|
<div className="px-4 py-3 border-b border-[#2a3044] flex items-center justify-between">
|
|||
|
|
<h3 className="text-sm font-bold text-white">Service Manager</h3>
|
|||
{loading
|
||||
|
|
? <span className="text-xs text-gray-600">Loading…</span>
|
|||
|
|
: <span className="text-xs text-gray-500">{running} / {services.length} running</span>
|
|||
|
|
}
|
|||
</div>
|
||||
|
|
<table className="w-full text-sm">
|
|||
|
|
<thead>
|
|||
|
|
<tr className="border-b border-[#2a3044]">
|
|||
{['Service', 'Status', 'PID', 'Uptime', 'Actions'].map(h => (
|
||||
|
|
<th key={h} className="text-left px-4 py-2 text-[10px] font-bold text-gray-500 uppercase tracking-wider hidden-last-two md:table-cell first:table-cell">{h}</th>
|
|||
|
|
))}
|
|||
</tr>
|
||||
|
|
</thead>
|
|||
|
|
<tbody>
|
|||
{loading ? (
|
||||
|
|
<tr>
|
|||
|
|
<td colSpan={5} className="px-4 py-8 text-center text-gray-600 text-sm">
|
|||
|
|
<span className="inline-block w-4 h-4 border-2 border-gray-700 border-t-orange-500 rounded-full animate-spin mr-2" />
|
|||
|
|
Loading services…
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
) : services.length === 0 ? (
|
|||
|
|
<tr>
|
|||
|
|
<td colSpan={5} className="px-4 py-8 text-center text-gray-600 text-sm">No service data available</td>
|
|||
|
|
</tr>
|
|||
|
|
) : services.map(svc => (
|
|||
<tr key={svc.name} className="border-b border-[#252b3d] hover:bg-[#252b3d]/50 transition-colors">
|
||||
<td className="px-4 py-2.5 text-gray-300 font-medium">{svc.display}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-semibold ${badge[svc.status] ?? badge.warning}`}>
|
||||
|
|
<span className={`w-1.5 h-1.5 rounded-full ${svc.status === 'running' ? 'bg-emerald-400 animate-pulse' : 'bg-red-400'}`} />
|
|||
{svc.status.charAt(0).toUpperCase() + svc.status.slice(1)}
|
||||
|
|
</span>
|
|||
|
|
</td>
|
|||
<td className="px-4 py-2.5 text-gray-500 text-xs hidden md:table-cell">{svc.pid ?? '—'}</td>
|
||||
|
|
<td className="px-4 py-2.5 text-gray-500 text-xs hidden md:table-cell">{svc.uptime ?? '—'}</td>
|
|||
<td className="px-4 py-2.5">
|
||||
|
|
<div className="flex gap-1.5">
|
|||
<button onClick={() => onAction(svc.name, 'start')} className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:bg-emerald-500/20 hover:text-emerald-400 text-gray-400 transition-colors">Start</button>
|
||||
|
|
<button onClick={() => onAction(svc.name, 'stop')} className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:bg-red-500/20 hover:text-red-400 text-gray-400 transition-colors">Stop</button>
|
|||
|
|
<button onClick={() => onAction(svc.name, 'restart')} className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:bg-orange-500/20 hover:text-orange-400 text-gray-400 transition-colors">Restart</button>
|
|||
</div>
|
||||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
))}
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
||||
/* ── Quick action grid ───────────────────────────────────────────────────── */
|
||||
function QuickActions({ onNav }: { onNav: (p: Page) => void }) {
|
||||
const actions = [
|
||||
|
|
{ label: 'Create Account', icon: '👤', page: 'createAccount' as Page, desc: 'Add hosting account' },
|
|||
|
|
{ label: 'Zone Manager', icon: '🌐', page: 'domains' as Page, desc: 'Manage DNS zones' },
|
|||
|
|
{ label: 'New Database', icon: '🗄️', page: 'databases' as Page, desc: 'Create MariaDB DB' },
|
|||
|
|
{ label: 'Issue SSL', icon: '🔒', page: 'ssl' as Page, desc: "Let's Encrypt cert" },
|
|||
|
|
{ label: 'Run Backup', icon: '💾', page: 'backups' as Page, desc: 'Full server backup' },
|
|||
|
|
{ label: 'PHP Manager', icon: '🐘', page: 'php' as Page, desc: 'Switch PHP version' },
|
|||
|
|
{ label: 'Firewall', icon: '🛡️', page: 'firewall' as Page, desc: 'Edit CSF rules' },
|
|||
|
|
{ label: 'System Logs', icon: '📜', page: 'logs' as Page, desc: 'Apache, error logs' },
|
|||
]
|
||||
|
|
return (
|
|||
|
|
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-lg overflow-hidden">
|
|||
|
|
<div className="px-4 py-3 border-b border-[#2a3044]">
|
|||
|
|
<h3 className="text-sm font-bold text-white">Quick Actions</h3>
|
|||
|
|
</div>
|
|||
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-0">
|
|||
|
|
{actions.map((a, i) => (
|
|||
<button key={a.label} onClick={() => onNav(a.page)}
|
||||
|
|
className={`flex flex-col items-center text-center p-4 hover:bg-orange-500/10 transition-all group ${i % 4 !== 3 ? 'border-r border-[#2a3044]' : ''} ${i < actions.length - 4 ? 'border-b border-[#2a3044]' : ''}`}
|
|||
>
|
||||
|
|
<span className="text-2xl mb-2 group-hover:scale-110 transition-transform">{a.icon}</span>
|
|||
|
|
<span className="text-xs font-semibold text-gray-300 group-hover:text-white leading-tight">{a.label}</span>
|
|||
|
|
<span className="text-[10px] text-gray-600 group-hover:text-gray-400 mt-0.5 leading-tight">{a.desc}</span>
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
/* ── Server info bar ─────────────────────────────────────────────────────── */
|
||||
|
|
function ServerInfoBar({ data }: { data: DashboardData | null }) {
|
|||
|
|
const si = data?.sysinfo
|
|||
|
|
const v = data?.versions
|
|||
const info = [
|
||||
{ label: 'Hostname', value: si?.hostname ?? '—' },
|
||||
|
|
{ label: 'OS', value: si?.os ?? '—' },
|
|||
|
|
{ label: 'Kernel', value: si?.kernel ?? '—' },
|
|||
|
|
{ label: 'PHP', value: v?.php ?? '—' },
|
|||
|
|
{ label: 'MariaDB', value: v?.mariadb ?? '—' },
|
|||
|
|
{ label: 'Apache', value: v?.apache ?? '—' },
|
|||
|
|
{ label: 'NixPanel', value: v?.nixpanel ?? '—' },
|
|||
]
|
||||
|
|
return (
|
|||
|
|
<div className="bg-[#0f1520] border border-[#2a3044] rounded-lg px-4 py-3 flex flex-wrap gap-x-6 gap-y-2">
|
|||
|
|
{info.map(i => (
|
|||
|
|
<div key={i.label} className="flex items-center gap-2">
|
|||
|
|
<span className="text-[10px] font-bold text-gray-600 uppercase tracking-wider">{i.label}:</span>
|
|||
|
|
<span className="text-xs text-gray-300 font-medium">{i.value}</span>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
{si && (
|
||||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<span className="text-[10px] font-bold text-gray-600 uppercase tracking-wider">Load:</span>
|
|||
|
|
<span className="text-xs text-gray-300 font-medium">{si.load_avg[0].toFixed(2)}</span>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
</div>
|
||||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
/* ── Dashboard page ──────────────────────────────────────────────────────── */
|
||||
function Dashboard({ onNav }: { onNav: (p: Page) => void }) {
|
||||
const [dashData, setDashData] = useState<DashboardData | null>(null)
|
||||
|
|
const [services, setServices] = useState<ServiceInfo[]>([])
|
|||
|
|
const [svcLoading, setSvcLoading] = useState(true)
|
|||
|
|
const [actionMsg, setActionMsg] = useState('')
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
api.getDashboard().then(setDashData).catch(console.error)
|
|||
|
|
}, [])
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
api.getServices().then(s => { setServices(s); setSvcLoading(false) }).catch(() => setSvcLoading(false))
|
|||
|
|
}, [])
|
|||
|
|
|
|||
|
|
const handleServiceAction = useCallback(async (name: string, action: 'start' | 'stop' | 'restart') => {
|
|||
|
|
setActionMsg(`${action}ing ${name}…`)
|
|||
|
|
try {
|
|||
|
|
await api.serviceAction(name, action)
|
|||
|
|
// Refresh services
|
|||
|
|
const updated = await api.getServices()
|
|||
|
|
setServices(updated)
|
|||
|
|
setActionMsg(`${name} ${action}ed`)
|
|||
|
|
} catch (e: any) {
|
|||
|
|
setActionMsg(`Error: ${e.message}`)
|
|||
|
|
}
|
|||
|
|
setTimeout(() => setActionMsg(''), 3000)
|
|||
|
|
}, [])
|
|||
|
|
|
|||
return (
|
||||
|
|
<div className="space-y-5">
|
|||
|
|
<div className="flex items-center justify-between">
|
|||
|
|
<div>
|
|||
|
|
<h1 className="text-lg font-black text-white">Server Overview</h1>
|
|||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
|
|
{dashData?.sysinfo
|
|||
|
|
? `${dashData.sysinfo.hostname} · ${dashData.sysinfo.ip}`
|
|||
|
|
: 'Welcome back, admin — NixServer WHM'}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center gap-3">
|
|||
|
|
{actionMsg && (
|
|||
|
|
<span className="text-xs text-orange-400 bg-orange-500/10 border border-orange-500/20 px-3 py-1.5 rounded-full">
|
|||
|
|
{actionMsg}
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
|
|
<span className="flex items-center gap-1.5 text-xs text-emerald-400 font-semibold bg-emerald-500/10 border border-emerald-500/20 px-3 py-1.5 rounded-full">
|
|||
|
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
|||
|
|
All systems operational
|
|||
|
|
</span>
|
|||
</div>
|
||||
|
|
</div>
|
|||
|
|
|
|||
<StatBar data={dashData} />
|
||||
|
|
<ServerInfoBar data={dashData} />
|
|||
|
||||
|
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-5">
|
|||
<ServiceTable services={services} loading={svcLoading} onAction={handleServiceAction} />
|
||||
<QuickActions onNav={onNav} />
|
||||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
/* ── List accounts ───────────────────────────────────────────────────────── */
|
||||
|
|
function ListAccounts({ onNav }: { onNav: (p: Page) => void }) {
|
|||
|
|
const [accounts, setAccounts] = useState<Account[]>([])
|
|||
|
|
const [loading, setLoading] = useState(true)
|
|||
|
|
const [search, setSearch] = useState('')
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
api.getAccounts()
|
|||
|
|
.then(setAccounts)
|
|||
|
|
.catch(console.error)
|
|||
|
|
.finally(() => setLoading(false))
|
|||
|
|
}, [])
|
|||
|
|
|
|||
|
|
const filtered = accounts.filter(a =>
|
|||
|
|
a.username.includes(search) || a.domain.includes(search)
|
|||
|
|
)
|
|||
|
|
|
|||
return (
|
||||
|
|
<div className="space-y-4">
|
|||
|
|
<div className="flex items-center justify-between">
|
|||
|
|
<h1 className="text-lg font-black text-white">Hosting Accounts</h1>
|
|||
<button onClick={() => onNav('createAccount')} className="px-3 py-1.5 bg-orange-500 hover:bg-orange-600 text-white text-sm font-semibold rounded transition-colors">
|
||||
+ Create Account
|
||||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-lg p-3 flex gap-3">
|
|||
<input type="text" value={search} onChange={e => setSearch(e.target.value)}
|
||||
|
|
placeholder="Search accounts, domains…"
|
|||
className="flex-1 bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm placeholder-gray-600 focus:outline-none focus:border-orange-500/50"
|
||||
|
|
/>
|
|||
|
|
<select className="bg-[#0f1520] border border-[#2a3044] text-gray-300 rounded px-3 py-2 text-sm focus:outline-none">
|
|||
<option>All Status</option><option>Active</option><option>Suspended</option>
|
||||
</select>
|
||||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-lg overflow-hidden">
|
|||
|
|
<table className="w-full text-sm">
|
|||
|
|
<thead>
|
|||
|
|
<tr className="border-b border-[#2a3044] bg-[#0f1520]">
|
|||
{['Username', 'Domain', 'Package', 'Disk Quota', 'Status', 'Created', 'Actions'].map(h => (
|
||||
<th key={h} className="text-left px-4 py-2.5 text-[10px] font-bold text-gray-500 uppercase tracking-wider">{h}</th>
|
||||
|
|
))}
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody>
|
|||
{loading ? (
|
||||
|
|
<tr><td colSpan={7} className="px-4 py-12 text-center text-gray-600 text-sm">
|
|||
|
|
<span className="inline-block w-4 h-4 border-2 border-gray-700 border-t-orange-500 rounded-full animate-spin mr-2" />Loading…
|
|||
|
|
</td></tr>
|
|||
|
|
) : filtered.length === 0 ? (
|
|||
|
|
<tr><td colSpan={7} className="px-4 py-16 text-center">
|
|||
<div className="text-3xl mb-3">👤</div>
|
||||
<p className="text-gray-500 text-sm">
|
||||
|
|
{search ? 'No accounts match your search.' : <>No accounts yet. <button onClick={() => onNav('createAccount')} className="text-orange-400 hover:underline">Create your first account →</button></>}
|
|||
|
|
</p>
|
|||
|
|
</td></tr>
|
|||
|
|
) : filtered.map(acc => (
|
|||
|
|
<tr key={acc.id} className="border-b border-[#252b3d] hover:bg-[#252b3d]/50 transition-colors">
|
|||
|
|
<td className="px-4 py-2.5 text-gray-300 font-medium">{acc.username}</td>
|
|||
|
|
<td className="px-4 py-2.5 text-gray-400">{acc.domain}</td>
|
|||
|
|
<td className="px-4 py-2.5 text-gray-500 text-xs">{acc.package_name}</td>
|
|||
|
|
<td className="px-4 py-2.5 text-gray-500 text-xs">{acc.disk_quota_mb === 0 ? '∞' : `${acc.disk_quota_mb} MB`}</td>
|
|||
|
|
<td className="px-4 py-2.5">
|
|||
|
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-semibold ${
|
|||
|
|
acc.status === 'active'
|
|||
|
|
? 'bg-emerald-500/20 text-emerald-400'
|
|||
|
|
: 'bg-red-500/20 text-red-400'
|
|||
|
|
}`}>
|
|||
|
|
<span className={`w-1.5 h-1.5 rounded-full ${acc.status === 'active' ? 'bg-emerald-400' : 'bg-red-400'}`} />
|
|||
|
|
{acc.status.charAt(0).toUpperCase() + acc.status.slice(1)}
|
|||
|
|
</span>
|
|||
|
|
</td>
|
|||
|
|
<td className="px-4 py-2.5 text-gray-600 text-xs">{new Date(acc.created_at).toLocaleDateString()}</td>
|
|||
|
|
<td className="px-4 py-2.5">
|
|||
|
|
<div className="flex gap-1.5">
|
|||
|
|
<button className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:text-orange-400 text-gray-400 transition-colors">Edit</button>
|
|||
|
|
<button className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:text-red-400 text-gray-400 transition-colors">Suspend</button>
|
|||
|
|
</div>
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
))}
|
|||
</tbody>
|
||||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
/* ── Create account form ─────────────────────────────────────────────────── */
|
||||
|
|
function CreateAccount({ onNav }: { onNav: (p: Page) => void }) {
|
|||
|
|
const [form, setForm] = useState({
|
|||
|
|
username: '', domain: '', email: '', password: '',
|
|||
|
|
package_name: 'Default', disk_quota_mb: 10240, bandwidth_mb: 0,
|
|||
|
|
})
|
|||
|
|
const [loading, setLoading] = useState(false)
|
|||
|
|
const [error, setError] = useState('')
|
|||
|
|
const [success, setSuccess] = useState('')
|
|||
|
|
|
|||
|
|
const set = (k: string, v: string | number) => setForm(f => ({ ...f, [k]: v }))
|
|||
|
|
|
|||
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|||
|
|
e.preventDefault()
|
|||
|
|
setLoading(true); setError(''); setSuccess('')
|
|||
|
|
try {
|
|||
|
|
const acc = await api.createAccount({
|
|||
|
|
...form,
|
|||
|
|
disk_quota_mb: Number(form.disk_quota_mb),
|
|||
|
|
bandwidth_mb: Number(form.bandwidth_mb),
|
|||
|
|
})
|
|||
|
|
setSuccess(`Account "${acc.username}" created for ${acc.domain}`)
|
|||
|
|
setTimeout(() => onNav('listAccounts'), 1500)
|
|||
|
|
} catch (e: any) {
|
|||
|
|
setError(e.message)
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const Field = ({ label, name, type = 'text', placeholder = '' }: { label: string; name: string; type?: string; placeholder?: string }) => (
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-semibold text-gray-400 mb-1.5">{label}</label>
|
|||
|
|
<input type={type} value={String((form as any)[name])} onChange={e => set(name, e.target.value)}
|
|||
|
|
placeholder={placeholder}
|
|||
|
|
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm placeholder-gray-600 focus:outline-none focus:border-orange-500/60"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
|
|||
return (
|
||||
|
|
<div className="max-w-2xl space-y-5">
|
|||
|
|
<h1 className="text-lg font-black text-white">Create Hosting Account</h1>
|
|||
|
|
|
|||
{error && <div className="px-4 py-3 bg-red-950/60 border border-red-800/50 rounded-lg text-red-400 text-sm">⚠️ {error}</div>}
|
||||
|
|
{success && <div className="px-4 py-3 bg-emerald-950/60 border border-emerald-800/50 rounded-lg text-emerald-400 text-sm">✓ {success}</div>}
|
|||
|
|
|
|||
|
|
<form onSubmit={handleSubmit} className="bg-[#1a1f2e] border border-[#2a3044] rounded-lg divide-y divide-[#2a3044]">
|
|||
<div className="p-5 space-y-4">
|
||||
|
|
<h2 className="text-xs font-bold text-orange-400 uppercase tracking-widest">Domain Information</h2>
|
|||
<Field label="Domain Name" name="domain" placeholder="example.com" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
<div className="p-5 space-y-4">
|
|||
|
|
<h2 className="text-xs font-bold text-orange-400 uppercase tracking-widest">Account Credentials</h2>
|
|||
|
|
<div className="grid grid-cols-2 gap-4">
|
|||
<Field label="Username" name="username" placeholder="username" />
|
||||
|
|
<Field label="Password" name="password" type="password" placeholder="••••••••" />
|
|||
<div className="col-span-2">
|
||||
<Field label="Email Address" name="email" type="email" placeholder="[email protected]" />
|
||||
</div>
|
||||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="p-5 space-y-4">
|
|||
|
|
<h2 className="text-xs font-bold text-orange-400 uppercase tracking-widest">Resource Limits</h2>
|
|||
|
|
<div className="grid grid-cols-2 gap-4">
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-semibold text-gray-400 mb-1.5">Package</label>
|
|||
<select value={form.package_name} onChange={e => set('package_name', e.target.value)}
|
||||
|
|
className="w-full bg-[#0f1520] border border-[#2a3044] text-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:border-orange-500/60">
|
|||
<option>Default</option>
|
||||
<option>Starter</option>
|
||||
|
|
<option>Professional</option>
|
|||
|
|
<option>Enterprise</option>
|
|||
</select>
|
||||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-semibold text-gray-400 mb-1.5">Disk Quota (MB)</label>
|
|||
<input type="number" value={form.disk_quota_mb} onChange={e => set('disk_quota_mb', e.target.value)}
|
||||
|
|
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm focus:outline-none focus:border-orange-500/60"
|
|||
|
|
/>
|
|||
</div>
|
||||
|
|
<div>
|
|||
<label className="block text-xs font-semibold text-gray-400 mb-1.5">Bandwidth (MB/mo, 0=∞)</label>
|
||||
|
|
<input type="number" value={form.bandwidth_mb} onChange={e => set('bandwidth_mb', e.target.value)}
|
|||
|
|
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm focus:outline-none focus:border-orange-500/60"
|
|||
|
|
/>
|
|||
</div>
|
||||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="p-5 flex gap-3">
|
|||
<button type="submit" disabled={loading}
|
||||
|
|
className="px-5 py-2 bg-orange-500 hover:bg-orange-600 disabled:opacity-60 text-white text-sm font-bold rounded transition-colors flex items-center gap-2">
|
|||
|
|
{loading && <span className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" />}
|
|||
|
|
{loading ? 'Creating…' : 'Create Account'}
|
|||
</button>
|
||||
<button type="button" onClick={() => onNav('listAccounts')}
|
||||
|
|
className="px-5 py-2 bg-[#0f1520] border border-[#2a3044] hover:border-gray-500 text-gray-400 hover:text-white text-sm rounded transition-colors">
|
|||
Cancel
|
||||
|
|
</button>
|
|||
|
|
</div>
|
|||
</form>
|
||||
</div>
|
||||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
/* ── Placeholder ─────────────────────────────────────────────────────────── */
|
||||
function Placeholder({ title, icon }: { title: string; icon: string }) {
|
||||
|
|
return (
|
|||
|
|
<div className="flex flex-col items-center justify-center h-72 text-center">
|
|||
|
|
<div className="text-5xl mb-4">{icon}</div>
|
|||
|
|
<h2 className="text-lg font-bold text-white mb-2">{title}</h2>
|
|||
<p className="text-gray-600 text-sm max-w-sm">This module is under active development and will be available in an upcoming release.</p>
|
||||
|
|
<div className="mt-4 px-3 py-1.5 bg-orange-500/10 border border-orange-500/20 rounded text-xs text-orange-400 font-semibold">Coming Soon</div>
|
|||
</div>
|
||||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const PAGE_META: Record<Page, { title: string; icon: string }> = {
|
|||
|
|
dashboard: { title: 'Server Dashboard', icon: '▦' },
|
|||
|
|
accounts: { title: 'Accounts', icon: '👤' },
|
|||
|
|
listAccounts: { title: 'List Accounts', icon: '📋' },
|
|||
|
|
createAccount: { title: 'Create Account', icon: '➕' },
|
|||
|
|
packages: { title: 'Feature Packages', icon: '📦' },
|
|||
|
|
domains: { title: 'Zone Manager', icon: '🌐' },
|
|||
|
|
dns: { title: 'DNS Records', icon: '🔗' },
|
|||
|
|
databases: { title: 'MySQL / MariaDB', icon: '🗄️' },
|
|||
|
|
email: { title: 'Email Routing', icon: '✉️' },
|
|||
|
|
ssl: { title: 'SSL / TLS Manager', icon: '🔒' },
|
|||
|
|
backups: { title: 'Backup Manager', icon: '💾' },
|
|||
firewall: { title: 'Firewall (CSF)', icon: '🛡️' },
|
||||
apache: { title: 'Apache Config', icon: '⚙️' },
|
||||
|
|
php: { title: 'PHP Manager', icon: '🐘' },
|
|||
|
|
services: { title: 'Service Manager', icon: '🔧' },
|
|||
|
|
settings: { title: 'Server Config', icon: '🔩' },
|
|||
|
|
logs: { title: 'System Logs', icon: '📜' },
|
|||
|
|
}
|
|||
|
|
|
|||
/* ── Admin panel shell ───────────────────────────────────────────────────── */
|
||||
|
|
function AdminPanel({ username, onLogout }: { username: string; onLogout: () => void }) {
|
|||
const [page, setPage] = useState<Page>('dashboard')
|
||||
|
|
const [collapsed, setCollapsed] = useState(false)
|
|||
|
|
const meta = PAGE_META[page]
|
|||
|
|
|
|||
|
|
function renderPage() {
|
|||
|
|
switch (page) {
|
|||
|
|
case 'dashboard': return <Dashboard onNav={setPage} />
|
|||
case 'listAccounts': return <ListAccounts onNav={setPage} />
|
||||
|
|
case 'createAccount': return <CreateAccount onNav={setPage} />
|
|||
default: return <Placeholder title={meta.title} icon={meta.icon} />
|
||||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="flex min-h-screen bg-[#0f1520] text-white">
|
|||
<Sidebar current={page} onNav={setPage} onLogout={onLogout} collapsed={collapsed} onToggle={() => setCollapsed(c => !c)} username={username} />
|
||||
|
||||
|
|
<div className="flex-1 flex flex-col min-w-0">
|
|||
|
|
<header className="h-14 border-b border-[#2a3044] bg-[#1a1f2e] flex items-center px-6 justify-between gap-4 flex-shrink-0">
|
|||
|
|
<div className="flex items-center gap-2 min-w-0">
|
|||
|
|
<span className="text-gray-600 text-sm">{meta.icon}</span>
|
|||
|
|
<h2 className="font-semibold text-gray-200 text-sm truncate">{meta.title}</h2>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center gap-3">
|
|||
|
|
<div className="hidden lg:flex items-center gap-2">
|
|||
<span className="text-xs bg-[#0f1520] border border-[#2a3044] text-gray-500 px-2 py-1 rounded">Ubuntu 24.04</span>
|
||||
</div>
|
||||
|
|
<div className="flex items-center gap-2 pl-3 border-l border-[#2a3044]">
|
|||
<div className="w-7 h-7 rounded-full bg-orange-500 flex items-center justify-center text-white text-xs font-bold">{username.charAt(0).toUpperCase()}</div>
|
||||
|
|
<span className="text-sm font-semibold text-gray-300 hidden sm:block">{username}</span>
|
|||
</div>
|
||||
|
|
</div>
|
|||
|
|
</header>
|
|||
|
|
|
|||
|
|
{page !== 'dashboard' && (
|
|||
|
|
<div className="px-6 py-2 border-b border-[#2a3044] bg-[#0f1520] flex items-center gap-1.5 text-xs text-gray-600">
|
|||
|
|
<button onClick={() => setPage('dashboard')} className="hover:text-orange-400 transition-colors">Dashboard</button>
|
|||
|
|
<span>/</span>
|
|||
|
|
<span className="text-gray-400">{meta.title}</span>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
<main className="flex-1 p-6 overflow-auto">{renderPage()}</main>
|
||||
|
||||
|
|
<footer className="border-t border-[#2a3044] px-6 py-2 flex items-center justify-between text-[10px] text-gray-700">
|
|||
|
|
<span>NixPanel WHM v0.1.0 · Ubuntu 24.04 LTS</span>
|
|||
|
|
<span>© 2025 NixPanel</span>
|
|||
|
|
</footer>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
/* ── Login ───────────────────────────────────────────────────────────────── */
|
||||
export default function App() {
|
||||
const [username, setUsername] = useState('')
|
||||
|
|
const [password, setPassword] = useState('')
|
|||
|
|
const [loggedInAs, setLoggedInAs] = useState<string | null>(null)
|
|||
|
|
const [error, setError] = useState('')
|
|||
|
|
const [loading, setLoading] = useState(false)
|
|||
|
||||
// Restore session from localStorage
|
||||
|
|
useEffect(() => {
|
|||
|
|
if (api.isLoggedIn()) {
|
|||
|
|
const stored = localStorage.getItem('nixpanel_username')
|
|||
|
|
setLoggedInAs(stored ?? 'admin')
|
|||
}
|
||||
}, [])
|
||||
|
|
|
|||
|
|
const handleLogin = async (e: React.FormEvent) => {
|
|||
|
|
e.preventDefault()
|
|||
|
|
setLoading(true); setError('')
|
|||
|
|
try {
|
|||
|
|
const result = await api.login(username, password)
|
|||
|
|
localStorage.setItem('nixpanel_username', result.user.username)
|
|||
|
|
setLoggedInAs(result.user.username)
|
|||
|
|
} catch (err: any) {
|
|||
|
|
setError(err.message === 'Unauthorized' ? 'Invalid username or password.' : err.message)
|
|||
|
|
} finally {
|
|||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
const handleLogout = () => {
|
||||
|
|
api.clearToken()
|
|||
|
|
localStorage.removeItem('nixpanel_username')
|
|||
|
|
setLoggedInAs(null)
|
|||
|
|
setUsername(''); setPassword('')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (loggedInAs) return <AdminPanel username={loggedInAs} onLogout={handleLogout} />
|
|||
|
||||
|
|
return (
|
|||
|
|
<div className="min-h-screen flex bg-[#0f1520]">
|
|||
|
|
{/* Left branding panel */}
|
|||
|
|
<div className="hidden lg:flex flex-col justify-between w-80 bg-[#1a1f2e] border-r border-[#2a3044] p-10">
|
|||
|
|
<div>
|
|||
|
|
<div className="flex items-center gap-3 mb-10">
|
|||
|
|
<div className="w-10 h-10 rounded-lg bg-orange-500 flex items-center justify-center text-white text-xl font-black shadow-lg shadow-orange-900/40">N</div>
|
|||
|
|
<div>
|
|||
|
|
<span className="text-xl font-black text-white">Nix<span className="text-orange-400">Server</span></span>
|
|||
|
|
<div className="text-[10px] font-bold text-gray-600 uppercase tracking-wider">WHM Control Panel</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div className="space-y-5">
|
|||
|
|
{[
|
|||
{ icon: '🌐', title: 'Full DNS Control', desc: 'PowerDNS-backed zone management' },
|
||||
|
|
{ icon: '🔒', title: 'Auto SSL/TLS', desc: "Let's Encrypt for all domains" },
|
|||
|
|
{ icon: '🛡️', title: 'Built-in Firewall', desc: 'CSF/Fail2ban integration' },
|
|||
|
|
{ icon: '💾', title: 'Automated Backups', desc: 'Scheduled full & incremental' },
|
|||
|
|
{ icon: '🐘', title: 'Multi-PHP Support', desc: 'PHP 8.0 – 8.3 per account' },
|
|||
].map(f => (
|
||||
|
|
<div key={f.title} className="flex items-start gap-3">
|
|||
|
|
<span className="text-lg mt-0.5">{f.icon}</span>
|
|||
|
|
<div>
|
|||
|
|
<p className="text-sm font-semibold text-gray-200">{f.title}</p>
|
|||
|
|
<p className="text-xs text-gray-600">{f.desc}</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
</div>
|
||||
<p className="text-[10px] text-gray-700">NixPanel WHM v0.1.0 · Secured by Fail2ban</p>
|
||||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Login form */}
|
|||
|
|
<div className="flex-1 flex items-center justify-center p-6">
|
|||
|
|
<div className="w-full max-w-sm">
|
|||
|
|
<div className="lg:hidden text-center mb-8">
|
|||
|
|
<span className="text-3xl font-black text-white">Nix<span className="text-orange-400">Server</span></span>
|
|||
|
|
<p className="text-xs text-gray-600 mt-1">WHM Control Panel</p>
|
|||
|
|
</div>
|
|||
|
||||
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-xl shadow-2xl overflow-hidden">
|
||||
|
|
<div className="bg-orange-500 px-6 py-4">
|
|||
|
|
<h1 className="text-white font-bold text-base">Administrator Login</h1>
|
|||
|
|
<p className="text-orange-200 text-xs mt-0.5">NixServer WHM · Root Access</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<form onSubmit={handleLogin} className="p-6 space-y-4">
|
|||
|
|
{error && (
|
|||
|
|
<div className="px-4 py-3 bg-red-950/60 border border-red-800/50 rounded-lg text-red-400 text-sm flex items-center gap-2">
|
|||
|
|
<span>⚠️</span> {error}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
<div>
|
|||
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1.5">Username</label>
|
||||
|
|
<input type="text" value={username} onChange={e => setUsername(e.target.value)} required autoFocus autoComplete="username" placeholder="admin"
|
|||
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded-lg px-4 py-2.5 text-sm placeholder-gray-700 focus:outline-none focus:border-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-all"
|
||||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1.5">Password</label>
|
||||
|
|
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required autoComplete="current-password" placeholder="••••••••••••"
|
|||
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded-lg px-4 py-2.5 text-sm placeholder-gray-700 focus:outline-none focus:border-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-all"
|
||||
|
|
/>
|
|||
|
|
</div>
|
|||
<button type="submit" disabled={loading}
|
||||
|
|
className="w-full py-2.5 rounded-lg bg-orange-500 hover:bg-orange-600 active:bg-orange-700 disabled:opacity-60 text-white font-bold text-sm transition-all shadow-lg shadow-orange-900/40 flex items-center justify-center gap-2">
|
|||
{loading ? (
|
||||
<><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />Authenticating…</>
|
||||
|
|
) : 'Sign in to WHM'}
|
|||
</button>
|
||||
|
|
</form>
|
|||
|
|
|
|||
|
|
<div className="border-t border-[#2a3044] px-6 py-3 bg-[#0f1520] flex items-center justify-between text-[10px] text-gray-700">
|
|||
|
|
<span>Secured by Fail2ban</span>
|
|||
|
|
<span>NixPanel v0.1</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
</div>
|
||||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|