feat: v0.4.0 — Apache service stack, shell access, log viewer, changelog

Service stack (backend):
- webserver.ts rewritten: apache2/php-fpm routes replacing nginx; per-account
  Apache log endpoint; PHP-FPM reload per version; apache2ctl configtest/reload
- accounts.ts: added PUT /:id/shell to toggle /bin/bash vs /usr/sbin/nologin,
  runs usermod -s and writes audit log

New UI features (nixserver):
- ShellAccess page: grant/revoke SSH access per account via AccountPicker
- ApacheLogs page: live log viewer (access/error) per account; replaces stub
- NginxLogs: re-exports ApacheLogs (NixPanel uses Apache, not NGINX)
- WebServer page: shows apache2, php8.2-fpm, php8.3-fpm status with reload
  buttons; config test; Apache log viewer

Bug fixes:
- Accounts.tsx: Lucide icon size prop type widened (string|number); removed
  unused Users import; dropped duplicate NGINX log card
- Removed soon badge from ShellAccess and ApacheLogs entries

Project:
- CHANGELOG.md created with full history from 0.2.x through 0.4.0
- All packages bumped from 0.3.2 to 0.4.0
This commit is contained in:
NixReaper
2026-04-11 05:17:49 -05:00
parent 10e31482b0
commit 8ff00c544c
65 changed files with 11223 additions and 1259 deletions
@@ -0,0 +1,133 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { Search, User } from 'lucide-react'
import { api } from '../../api/client'
export interface PickedAccount {
id: number
username: string
domain: string
email: string
status: string
ipAddress: string | null
package?: { name: string }
}
interface Props {
onSelect: (account: PickedAccount) => void
selected?: PickedAccount | null
placeholder?: string
}
const statusColor: Record<string, string> = {
active: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
suspended: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
terminated: 'bg-red-500/10 text-red-400 border-red-500/20',
}
export default function AccountPicker({ onSelect, selected, placeholder = 'Search by username, domain, or email...' }: Props) {
const [search, setSearch] = useState('')
const [results, setResults] = useState<PickedAccount[]>([])
const [loading, setLoading] = useState(false)
const [open, setOpen] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const doSearch = useCallback(async (q: string) => {
if (!q.trim()) { setResults([]); setOpen(false); return }
setLoading(true)
try {
const r = await api.get(`/nixserver/accounts?search=${encodeURIComponent(q)}&limit=10`)
setResults(r.data.data.items)
setOpen(true)
} catch {
setResults([])
} finally {
setLoading(false)
}
}, [])
const handleInput = (val: string) => {
setSearch(val)
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => doSearch(val), 300)
}
const handleSelect = (acc: PickedAccount) => {
onSelect(acc)
setSearch('')
setResults([])
setOpen(false)
}
useEffect(() => {
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
return (
<div ref={containerRef} className="space-y-3">
<div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#64748b]" />
{loading && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
)}
<input
value={search}
onChange={e => handleInput(e.target.value)}
placeholder={placeholder}
className="w-full bg-[#1a1d27] border border-[#2a2d3e] text-white text-sm rounded-lg pl-9 pr-9 py-2 focus:outline-none focus:border-indigo-500"
/>
{open && results.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 z-20 bg-[#1e2130] border border-[#2a2d3e] rounded-lg shadow-xl overflow-hidden">
{results.map(acc => (
<button
key={acc.id}
onClick={() => handleSelect(acc)}
className="flex items-center gap-3 w-full px-4 py-3 hover:bg-[#2a2d3e] transition-colors text-left border-b border-[#2a2d3e] last:border-0"
>
<div className="w-7 h-7 rounded-full bg-indigo-600/20 flex items-center justify-center flex-shrink-0">
<User size={14} className="text-indigo-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-white text-sm font-medium">{acc.username}</p>
<p className="text-[#64748b] text-xs truncate">{acc.domain} &mdash; {acc.email}</p>
</div>
<span className={`text-xs px-2 py-0.5 rounded-full border flex-shrink-0 ${statusColor[acc.status] ?? ''}`}>
{acc.status}
</span>
</button>
))}
</div>
)}
{open && !loading && results.length === 0 && search.trim() && (
<div className="absolute top-full left-0 right-0 mt-1 z-20 bg-[#1e2130] border border-[#2a2d3e] rounded-lg shadow-xl px-4 py-3">
<p className="text-[#64748b] text-sm">No accounts found</p>
</div>
)}
</div>
{selected && (
<div className="bg-[#1a1d27] border border-indigo-500/30 rounded-lg p-4 flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-indigo-600/20 flex items-center justify-center flex-shrink-0">
<User size={18} className="text-indigo-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-white font-medium">{selected.username}</p>
<p className="text-[#64748b] text-xs truncate">{selected.domain} &mdash; {selected.email}</p>
{selected.package && (
<p className="text-[#64748b] text-xs">Package: {selected.package.name}</p>
)}
</div>
<span className={`text-xs px-2 py-0.5 rounded-full border flex-shrink-0 ${statusColor[selected.status] ?? ''}`}>
{selected.status}
</span>
</div>
)}
</div>
)
}
+114
View File
@@ -0,0 +1,114 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { ChevronLeft, FileText, RefreshCw } from 'lucide-react'
import { api } from '../../api/client'
import AccountPicker, { type PickedAccount } from './AccountPicker'
export default function ApacheLogs() {
const [selected, setSelected] = useState<PickedAccount | null>(null)
const [logType, setLogType] = useState<'access' | 'error'>('access')
const [lines, setLines] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const fetchLogs = async (username: string, type: 'access' | 'error') => {
setLoading(true); setError('')
try {
const r = await api.get(`/nixserver/webserver/accounts/${username}/logs/${type}`)
setLines(r.data.data.lines ?? [])
} catch (e: unknown) {
setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed to load logs')
setLines([])
} finally { setLoading(false) }
}
const handleSelect = (acc: PickedAccount) => {
setSelected(acc)
setLines([])
setError('')
fetchLogs(acc.username, logType)
}
const handleTabChange = (type: 'access' | 'error') => {
setLogType(type)
if (selected) fetchLogs(selected.username, type)
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm">
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
<ChevronLeft size={14} /> Accounts
</Link>
<span className="text-[#2a2d3e]">/</span>
<span className="text-white">Apache Log Viewer</span>
</div>
<div className="flex items-center justify-between">
<div>
<h1 className="text-white text-xl font-semibold">Apache Log Viewer</h1>
<p className="text-[#64748b] text-sm">View Apache access and error logs per hosting account.</p>
</div>
{selected && (
<button
onClick={() => fetchLogs(selected.username, logType)}
className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg"
>
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
</button>
)}
</div>
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-5 space-y-4">
<AccountPicker onSelect={handleSelect} selected={selected} />
{selected && (
<div className="space-y-3 pt-2 border-t border-[#2a2d3e]">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileText size={14} className="text-indigo-400" />
<span className="text-[#94a3b8] text-sm font-mono">/home/{selected.username}/logs/</span>
</div>
<div className="flex gap-1 bg-[#0f1117] border border-[#2a2d3e] rounded-lg p-0.5">
{(['access', 'error'] as const).map(t => (
<button
key={t}
onClick={() => handleTabChange(t)}
className={`px-3 py-1 text-xs rounded ${logType === t ? 'bg-indigo-600 text-white' : 'text-[#64748b] hover:text-white'}`}
>
{t}.log
</button>
))}
</div>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-400 text-xs px-3 py-2 rounded">
{error}
</div>
)}
<div className="bg-[#0f1117] border border-[#2a2d3e] rounded-xl overflow-hidden">
<div className="max-h-96 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center h-32 text-[#64748b] text-sm">Loading logs...</div>
) : lines.length === 0 ? (
<div className="flex items-center justify-center h-32 text-[#64748b] text-sm">No log entries found</div>
) : (
<pre className="p-4 text-xs text-[#64748b] font-mono leading-relaxed whitespace-pre-wrap">
{lines.join('\n')}
</pre>
)}
</div>
{lines.length > 0 && (
<div className="border-t border-[#2a2d3e] px-4 py-2 text-xs text-[#64748b]">
Showing last {lines.length} lines
</div>
)}
</div>
</div>
)}
</div>
</div>
)
}
+117
View File
@@ -0,0 +1,117 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { ChevronLeft, RefreshCw } from 'lucide-react'
import { api } from '../../api/client'
interface BandwidthAccount {
id: number
username: string
domain: string
status: string
bandwidthUsedMb: number
package?: { name: string; bandwidthMb: number }
}
export default function BandwidthUsage() {
const [items, setItems] = useState<BandwidthAccount[]>([])
const [loading, setLoading] = useState(false)
const fetchData = async () => {
setLoading(true)
try {
const r = await api.get('/nixserver/accounts/bandwidth')
setItems(r.data.data)
} finally {
setLoading(false)
}
}
useEffect(() => { fetchData() }, [])
const statusColor: Record<string, string> = {
active: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
suspended: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
terminated: 'bg-red-500/10 text-red-400 border-red-500/20',
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm">
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
<ChevronLeft size={14} /> Accounts
</Link>
<span className="text-[#2a2d3e]">/</span>
<span className="text-white">View Bandwidth Usage</span>
</div>
<div className="flex items-center justify-between">
<div>
<h1 className="text-white text-xl font-semibold">View Bandwidth Usage</h1>
<p className="text-[#64748b] text-sm">{items.length} account{items.length !== 1 ? 's' : ''}, sorted by usage</p>
</div>
<button onClick={fetchData} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg transition-colors">
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
</button>
</div>
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#2a2d3e]">
{['Username', 'Domain', 'Bandwidth Used', 'Bandwidth Limit', '% Used', 'Status'].map(h => (
<th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>
))}
</tr>
</thead>
<tbody>
{loading && (
<tr><td colSpan={6} className="text-center text-[#64748b] py-8">Loading...</td></tr>
)}
{!loading && items.map(acc => {
const limit = acc.package?.bandwidthMb ?? 0
const pct = limit > 0 ? Math.min(100, Math.round((acc.bandwidthUsedMb / limit) * 100)) : 0
const barColor = pct >= 90 ? 'bg-red-500' : pct >= 70 ? 'bg-amber-500' : 'bg-indigo-500'
return (
<tr key={acc.id} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130] transition-colors">
<td className="px-4 py-3">
<Link to={`/accounts/${acc.id}`} className="text-indigo-400 hover:text-indigo-300 font-medium">
{acc.username}
</Link>
</td>
<td className="px-4 py-3 text-[#94a3b8]">{acc.domain}</td>
<td className="px-4 py-3 text-[#94a3b8] font-mono text-xs">{acc.bandwidthUsedMb.toLocaleString()} MB</td>
<td className="px-4 py-3 text-[#94a3b8] font-mono text-xs">
{limit > 0 ? `${limit.toLocaleString()} MB` : '—'}
</td>
<td className="px-4 py-3 min-w-[140px]">
{limit > 0 ? (
<div className="flex items-center gap-2">
<div className="flex-1 bg-[#2a2d3e] rounded-full h-1.5 overflow-hidden">
<div
className={`h-full rounded-full transition-all ${barColor}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-[#94a3b8] text-xs font-mono">{pct}%</span>
</div>
) : (
<span className="text-[#64748b] text-xs">No limit</span>
)}
</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded-full border ${statusColor[acc.status] ?? ''}`}>
{acc.status}
</span>
</td>
</tr>
)
})}
{!loading && items.length === 0 && (
<tr><td colSpan={6} className="text-center text-[#64748b] py-8">No accounts found</td></tr>
)}
</tbody>
</table>
</div>
</div>
)
}
+35
View File
@@ -0,0 +1,35 @@
import { Link } from 'react-router-dom'
import { ChevronLeft, Network, Clock } from 'lucide-react'
export default function BulkIP() {
return (
<div className="space-y-4 max-w-xl">
<div className="flex items-center gap-2 text-sm">
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
<ChevronLeft size={14} /> Accounts
</Link>
<span className="text-[#2a2d3e]">/</span>
<span className="text-white">Change Multiple Sites' IP Addresses</span>
</div>
<div>
<h1 className="text-white text-xl font-semibold">Change Multiple Sites' IP Addresses</h1>
<p className="text-[#64748b] text-sm">Batch-change IP addresses across multiple accounts.</p>
</div>
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-8 flex flex-col items-center gap-4 text-center">
<div className="w-14 h-14 rounded-full bg-orange-500/10 flex items-center justify-center">
<Network size={28} className="text-orange-400" />
</div>
<div>
<div className="flex items-center justify-center gap-2 mb-2">
<Clock size={14} className="text-orange-400" />
<span className="text-orange-400 text-sm font-medium">Coming Soon</span>
</div>
<p className="text-white font-semibold text-lg mb-1">Change Multiple Sites' IP Addresses</p>
<p className="text-[#64748b] text-sm max-w-sm">
This feature will allow you to batch-reassign IP addresses across multiple hosting accounts simultaneously, useful for IP migrations.
</p>
</div>
</div>
</div>
)
}
@@ -0,0 +1,35 @@
import { Link } from 'react-router-dom'
import { ChevronLeft, Users, Clock } from 'lucide-react'
export default function BulkModify() {
return (
<div className="space-y-4 max-w-xl">
<div className="flex items-center gap-2 text-sm">
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
<ChevronLeft size={14} /> Accounts
</Link>
<span className="text-[#2a2d3e]">/</span>
<span className="text-white">Modify / Upgrade Multiple Accounts</span>
</div>
<div>
<h1 className="text-white text-xl font-semibold">Modify / Upgrade Multiple Accounts</h1>
<p className="text-[#64748b] text-sm">Apply package upgrades or setting changes to multiple accounts simultaneously.</p>
</div>
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-8 flex flex-col items-center gap-4 text-center">
<div className="w-14 h-14 rounded-full bg-orange-500/10 flex items-center justify-center">
<Users size={28} className="text-orange-400" />
</div>
<div>
<div className="flex items-center justify-center gap-2 mb-2">
<Clock size={14} className="text-orange-400" />
<span className="text-orange-400 text-sm font-medium">Coming Soon</span>
</div>
<p className="text-white font-semibold text-lg mb-1">Modify / Upgrade Multiple Accounts</p>
<p className="text-[#64748b] text-sm max-w-sm">
This feature will allow you to apply package upgrades, setting changes, or bulk modifications to multiple hosting accounts at once.
</p>
</div>
</div>
</div>
)
}
+90
View File
@@ -0,0 +1,90 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { ChevronLeft, Network } from 'lucide-react'
import { api } from '../../api/client'
import AccountPicker, { type PickedAccount } from './AccountPicker'
export default function ChangeIP() {
const [selected, setSelected] = useState<PickedAccount | null>(null)
const [ipAddress, setIpAddress] = useState('')
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const handleSelect = (acc: PickedAccount) => {
setSelected(acc)
setIpAddress(acc.ipAddress ?? '')
setMessage('')
setError('')
}
const handleSubmit = async () => {
if (!selected || !ipAddress.trim()) return
setLoading(true); setMessage(''); setError('')
try {
await api.post(`/nixserver/accounts/${selected.id}/change-ip`, { ipAddress: ipAddress.trim() })
setMessage(`IP address for "${selected.username}" updated to ${ipAddress.trim()}.`)
setSelected(prev => prev ? { ...prev, ipAddress: ipAddress.trim() } : null)
} catch (e: unknown) {
setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed')
} finally {
setLoading(false)
}
}
return (
<div className="space-y-4 max-w-xl">
<div className="flex items-center gap-2 text-sm">
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
<ChevronLeft size={14} /> Accounts
</Link>
<span className="text-[#2a2d3e]">/</span>
<span className="text-white">Change Site's IP Address</span>
</div>
<div>
<h1 className="text-white text-xl font-semibold">Change Site's IP Address</h1>
<p className="text-[#64748b] text-sm">Assign a different IP address to a hosting account.</p>
</div>
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-5 space-y-4">
<AccountPicker onSelect={handleSelect} selected={selected} />
{selected && (
<div className="space-y-4 pt-2 border-t border-[#2a2d3e]">
<div className="bg-[#0f1117] border border-[#2a2d3e] rounded-lg px-4 py-3">
<p className="text-[#64748b] text-xs mb-1">Current IP Address</p>
<p className="text-white font-mono">{selected.ipAddress ?? 'Not assigned'}</p>
</div>
<div className="space-y-2">
<label className="block text-[#94a3b8] text-sm font-medium">New IP Address</label>
<input
type="text"
value={ipAddress}
onChange={e => setIpAddress(e.target.value)}
placeholder="e.g. 192.168.1.100"
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white font-mono text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500"
/>
</div>
<button
onClick={handleSubmit}
disabled={loading || !ipAddress.trim()}
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-4 py-2 rounded-lg transition-colors disabled:opacity-50"
>
<Network size={16} /> {loading ? 'Updating...' : 'Update IP Address'}
</button>
</div>
)}
{message && (
<p className="text-emerald-400 text-sm bg-emerald-500/10 border border-emerald-500/20 rounded-lg px-4 py-3">{message}</p>
)}
{error && (
<p className="text-red-400 text-sm bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">{error}</p>
)}
</div>
</div>
)
}
+35
View File
@@ -0,0 +1,35 @@
import { Link } from 'react-router-dom'
import { ChevronLeft, FlaskConical, Clock } from 'lucide-react'
export default function DemoMode() {
return (
<div className="space-y-4 max-w-xl">
<div className="flex items-center gap-2 text-sm">
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
<ChevronLeft size={14} /> Accounts
</Link>
<span className="text-[#2a2d3e]">/</span>
<span className="text-white">Manage Demo Mode</span>
</div>
<div>
<h1 className="text-white text-xl font-semibold">Manage Demo Mode</h1>
<p className="text-[#64748b] text-sm">Put accounts into read-only demo mode.</p>
</div>
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-8 flex flex-col items-center gap-4 text-center">
<div className="w-14 h-14 rounded-full bg-orange-500/10 flex items-center justify-center">
<FlaskConical size={28} className="text-orange-400" />
</div>
<div>
<div className="flex items-center justify-center gap-2 mb-2">
<Clock size={14} className="text-orange-400" />
<span className="text-orange-400 text-sm font-medium">Coming Soon</span>
</div>
<p className="text-white font-semibold text-lg mb-1">Manage Demo Mode</p>
<p className="text-[#64748b] text-sm max-w-sm">
This feature will allow you to put hosting accounts into a read-only demo mode, preventing any changes or file modifications.
</p>
</div>
</div>
</div>
)
}
@@ -0,0 +1,35 @@
import { Link } from 'react-router-dom'
import { ChevronLeft, Mail, Clock } from 'lucide-react'
export default function EmailUsers() {
return (
<div className="space-y-4 max-w-xl">
<div className="flex items-center gap-2 text-sm">
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
<ChevronLeft size={14} /> Accounts
</Link>
<span className="text-[#2a2d3e]">/</span>
<span className="text-white">Email All Users</span>
</div>
<div>
<h1 className="text-white text-xl font-semibold">Email All Users</h1>
<p className="text-[#64748b] text-sm">Send a bulk email to all account holders.</p>
</div>
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-8 flex flex-col items-center gap-4 text-center">
<div className="w-14 h-14 rounded-full bg-orange-500/10 flex items-center justify-center">
<Mail size={28} className="text-orange-400" />
</div>
<div>
<div className="flex items-center justify-center gap-2 mb-2">
<Clock size={14} className="text-orange-400" />
<span className="text-orange-400 text-sm font-medium">Coming Soon</span>
</div>
<p className="text-white font-semibold text-lg mb-1">Email All Users</p>
<p className="text-[#64748b] text-sm max-w-sm">
This feature will allow you to compose and send a bulk email to all hosting account holders simultaneously.
</p>
</div>
</div>
</div>
)
}
@@ -0,0 +1,35 @@
import { Link } from 'react-router-dom'
import { ChevronLeft, KeyRound, Clock } from 'lucide-react'
export default function ForcePassword() {
return (
<div className="space-y-4 max-w-xl">
<div className="flex items-center gap-2 text-sm">
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
<ChevronLeft size={14} /> Accounts
</Link>
<span className="text-[#2a2d3e]">/</span>
<span className="text-white">Force Password Change</span>
</div>
<div>
<h1 className="text-white text-xl font-semibold">Force Password Change</h1>
<p className="text-[#64748b] text-sm">Force users to change their password on next login.</p>
</div>
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-8 flex flex-col items-center gap-4 text-center">
<div className="w-14 h-14 rounded-full bg-orange-500/10 flex items-center justify-center">
<KeyRound size={28} className="text-orange-400" />
</div>
<div>
<div className="flex items-center justify-center gap-2 mb-2">
<Clock size={14} className="text-orange-400" />
<span className="text-orange-400 text-sm font-medium">Coming Soon</span>
</div>
<p className="text-white font-semibold text-lg mb-1">Force Password Change</p>
<p className="text-[#64748b] text-sm max-w-sm">
This feature will allow you to flag one or more accounts to require a password change on their next login.
</p>
</div>
</div>
</div>
)
}
@@ -0,0 +1,35 @@
import { Link } from 'react-router-dom'
import { ChevronLeft, Gauge, Clock } from 'lucide-react'
export default function LimitBandwidth() {
return (
<div className="space-y-4 max-w-xl">
<div className="flex items-center gap-2 text-sm">
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
<ChevronLeft size={14} /> Accounts
</Link>
<span className="text-[#2a2d3e]">/</span>
<span className="text-white">Limit Bandwidth Usage</span>
</div>
<div>
<h1 className="text-white text-xl font-semibold">Limit Bandwidth Usage</h1>
<p className="text-[#64748b] text-sm">Set per-account bandwidth caps independent of package.</p>
</div>
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-8 flex flex-col items-center gap-4 text-center">
<div className="w-14 h-14 rounded-full bg-orange-500/10 flex items-center justify-center">
<Gauge size={28} className="text-orange-400" />
</div>
<div>
<div className="flex items-center justify-center gap-2 mb-2">
<Clock size={14} className="text-orange-400" />
<span className="text-orange-400 text-sm font-medium">Coming Soon</span>
</div>
<p className="text-white font-semibold text-lg mb-1">Limit Bandwidth Usage</p>
<p className="text-[#64748b] text-sm max-w-sm">
This feature will allow you to set custom per-account bandwidth caps that override the package-level limits.
</p>
</div>
</div>
</div>
)
}
+207
View File
@@ -0,0 +1,207 @@
import { useEffect, useState, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { Plus, Search, MoreVertical, UserX, UserCheck, Trash2, RefreshCw, ChevronLeft } from 'lucide-react'
import { api } from '../../api/client'
interface Account {
id: number
username: string
domain: string
email: string
status: string
diskUsedMb: number
bandwidthUsedMb: number
ipAddress: string | null
createdAt: string
package?: { name: string; diskMb: number }
}
interface PageData {
items: Account[]
total: number
page: number
totalPages: number
}
const statusColor: Record<string, string> = {
active: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
suspended: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
terminated: 'bg-red-500/10 text-red-400 border-red-500/20',
}
export default function ListAccounts() {
const [data, setData] = useState<PageData | null>(null)
const [search, setSearch] = useState('')
const [status, setStatus] = useState('')
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(false)
const [actionMenu, setActionMenu] = useState<number | null>(null)
const fetchData = useCallback(async () => {
setLoading(true)
try {
const params = new URLSearchParams({ page: String(page), limit: '25' })
if (search) params.set('search', search)
if (status) params.set('status', status)
const r = await api.get(`/nixserver/accounts?${params}`)
setData(r.data.data)
} finally {
setLoading(false)
}
}, [page, search, status])
useEffect(() => { fetchData() }, [fetchData])
const handleAction = async (id: number, act: 'suspend' | 'unsuspend' | 'terminate') => {
setActionMenu(null)
if (act === 'terminate' && !confirm('Terminate this account? This cannot be undone.')) return
try {
if (act === 'suspend') await api.post(`/nixserver/accounts/${id}/suspend`)
else if (act === 'unsuspend') await api.post(`/nixserver/accounts/${id}/unsuspend`)
else await api.delete(`/nixserver/accounts/${id}`)
fetchData()
} catch (e: unknown) {
alert((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed')
}
}
return (
<div className="space-y-4">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm">
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
<ChevronLeft size={14} /> Accounts
</Link>
<span className="text-[#2a2d3e]">/</span>
<span className="text-white">List Accounts</span>
</div>
<div className="flex items-center justify-between">
<div>
<h1 className="text-white text-xl font-semibold">List Accounts</h1>
<p className="text-[#64748b] text-sm">{data?.total ?? 0} total accounts</p>
</div>
<Link
to="/accounts/create"
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-3 py-2 rounded-lg transition-colors"
>
<Plus size={16} /> Create Account
</Link>
</div>
{/* Filters */}
<div className="flex gap-3">
<div className="relative flex-1 max-w-sm">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#64748b]" />
<input
value={search}
onChange={e => { setSearch(e.target.value); setPage(1) }}
placeholder="Search username, domain, email..."
className="w-full bg-[#1a1d27] border border-[#2a2d3e] text-white text-sm rounded-lg pl-9 pr-3 py-2 focus:outline-none focus:border-indigo-500"
/>
</div>
<select
value={status}
onChange={e => { setStatus(e.target.value); setPage(1) }}
className="bg-[#1a1d27] border border-[#2a2d3e] text-[#94a3b8] text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500"
>
<option value="">All statuses</option>
<option value="active">Active</option>
<option value="suspended">Suspended</option>
<option value="terminated">Terminated</option>
</select>
<button onClick={fetchData} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg transition-colors">
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
</button>
</div>
{/* Table */}
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#2a2d3e]">
{['Username', 'Domain', 'Package', 'Status', 'IP', 'Created', ''].map(h => (
<th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>
))}
</tr>
</thead>
<tbody>
{loading && !data && (
<tr><td colSpan={7} className="text-center text-[#64748b] py-8">Loading...</td></tr>
)}
{data?.items.map(acc => (
<tr key={acc.id} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130] transition-colors">
<td className="px-4 py-3">
<Link to={`/accounts/${acc.id}`} className="text-indigo-400 hover:text-indigo-300 font-medium">
{acc.username}
</Link>
<p className="text-[#64748b] text-xs">{acc.email}</p>
</td>
<td className="px-4 py-3 text-[#94a3b8]">{acc.domain}</td>
<td className="px-4 py-3 text-[#94a3b8]">{acc.package?.name ?? '—'}</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded-full border ${statusColor[acc.status] ?? ''}`}>
{acc.status}
</span>
</td>
<td className="px-4 py-3 text-[#64748b] font-mono text-xs">{acc.ipAddress ?? '—'}</td>
<td className="px-4 py-3 text-[#64748b] text-xs">{new Date(acc.createdAt).toLocaleDateString()}</td>
<td className="px-4 py-3 relative">
<button
onClick={() => setActionMenu(actionMenu === acc.id ? null : acc.id)}
className="p-1 text-[#64748b] hover:text-white rounded transition-colors"
>
<MoreVertical size={16} />
</button>
{actionMenu === acc.id && (
<div className="absolute right-4 top-8 z-10 bg-[#1e2130] border border-[#2a2d3e] rounded-lg shadow-lg py-1 w-36">
{acc.status === 'active' ? (
<button onClick={() => handleAction(acc.id, 'suspend')} className="flex items-center gap-2 w-full px-3 py-2 text-amber-400 hover:bg-[#2a2d3e] text-xs">
<UserX size={14} /> Suspend
</button>
) : acc.status === 'suspended' ? (
<button onClick={() => handleAction(acc.id, 'unsuspend')} className="flex items-center gap-2 w-full px-3 py-2 text-emerald-400 hover:bg-[#2a2d3e] text-xs">
<UserCheck size={14} /> Unsuspend
</button>
) : null}
<button onClick={() => handleAction(acc.id, 'terminate')} className="flex items-center gap-2 w-full px-3 py-2 text-red-400 hover:bg-[#2a2d3e] text-xs">
<Trash2 size={14} /> Terminate
</button>
</div>
)}
</td>
</tr>
))}
{data?.items.length === 0 && (
<tr><td colSpan={7} className="text-center text-[#64748b] py-8">No accounts found</td></tr>
)}
</tbody>
</table>
{data && data.totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-[#2a2d3e]">
<span className="text-[#64748b] text-xs">
Page {data.page} of {data.totalPages} ({data.total} total)
</span>
<div className="flex gap-2">
<button
onClick={() => setPage(p => p - 1)}
disabled={page === 1}
className="px-3 py-1 text-xs bg-[#1e2130] border border-[#2a2d3e] rounded text-[#94a3b8] disabled:opacity-40 hover:border-indigo-500 transition-colors"
>
Prev
</button>
<button
onClick={() => setPage(p => p + 1)}
disabled={page >= data.totalPages}
className="px-3 py-1 text-xs bg-[#1e2130] border border-[#2a2d3e] rounded text-[#94a3b8] disabled:opacity-40 hover:border-indigo-500 transition-colors"
>
Next
</button>
</div>
</div>
)}
</div>
</div>
)
}
+129
View File
@@ -0,0 +1,129 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { ChevronLeft, Save } from 'lucide-react'
import { api } from '../../api/client'
import AccountPicker, { type PickedAccount } from './AccountPicker'
interface Package {
id: number
name: string
diskMb: number
bandwidthMb: number
}
export default function Modify() {
const [selected, setSelected] = useState<PickedAccount | null>(null)
const [email, setEmail] = useState('')
const [notes, setNotes] = useState('')
const [packageId, setPackageId] = useState<string>('')
const [packages, setPackages] = useState<Package[]>([])
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
const [error, setError] = useState('')
useEffect(() => {
api.get('/nixserver/packages').then(r => setPackages(r.data.data?.items ?? r.data.data ?? [])).catch(() => {})
}, [])
const handleSelect = (acc: PickedAccount) => {
setSelected(acc)
setEmail(acc.email)
setNotes('')
setPackageId('')
setMessage('')
setError('')
}
const handleSubmit = async () => {
if (!selected) return
setLoading(true); setMessage(''); setError('')
const body: Record<string, unknown> = {}
if (email && email !== selected.email) body.email = email
if (notes) body.notes = notes
if (packageId) body.packageId = parseInt(packageId, 10)
try {
await api.put(`/nixserver/accounts/${selected.id}`, body)
setMessage(`Account "${selected.username}" has been updated.`)
} catch (e: unknown) {
setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed')
} finally {
setLoading(false)
}
}
return (
<div className="space-y-4 max-w-xl">
<div className="flex items-center gap-2 text-sm">
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
<ChevronLeft size={14} /> Accounts
</Link>
<span className="text-[#2a2d3e]">/</span>
<span className="text-white">Modify an Account</span>
</div>
<div>
<h1 className="text-white text-xl font-semibold">Modify an Account</h1>
<p className="text-[#64748b] text-sm">Update account settings such as email, notes, or package.</p>
</div>
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-5 space-y-4">
<AccountPicker onSelect={handleSelect} selected={selected} />
{selected && (
<div className="space-y-4 pt-2 border-t border-[#2a2d3e]">
<div className="space-y-2">
<label className="block text-[#94a3b8] text-sm font-medium">Email Address</label>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500"
/>
</div>
<div className="space-y-2">
<label className="block text-[#94a3b8] text-sm font-medium">Package</label>
<select
value={packageId}
onChange={e => setPackageId(e.target.value)}
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-[#94a3b8] text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500"
>
<option value="">— Keep current ({selected.package?.name ?? 'none'}) —</option>
{packages.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="block text-[#94a3b8] text-sm font-medium">Notes</label>
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
rows={3}
placeholder="Internal notes about this account..."
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500 resize-none"
/>
</div>
<button
onClick={handleSubmit}
disabled={loading}
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-4 py-2 rounded-lg transition-colors disabled:opacity-50"
>
<Save size={16} /> {loading ? 'Saving...' : 'Save Changes'}
</button>
</div>
)}
{message && (
<p className="text-emerald-400 text-sm bg-emerald-500/10 border border-emerald-500/20 rounded-lg px-4 py-3">{message}</p>
)}
{error && (
<p className="text-red-400 text-sm bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">{error}</p>
)}
</div>
</div>
)
}
@@ -0,0 +1,2 @@
// This page is an alias for ApacheLogs — NixPanel uses Apache, not NGINX.
export { default } from './ApacheLogs'
+112
View File
@@ -0,0 +1,112 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { ChevronLeft, RefreshCw } from 'lucide-react'
import { api } from '../../api/client'
interface OverQuotaAccount {
id: number
username: string
domain: string
status: string
diskUsedMb: number
package: { name: string; diskMb: number; bandwidthMb: number }
}
export default function OverQuota() {
const [items, setItems] = useState<OverQuotaAccount[]>([])
const [loading, setLoading] = useState(false)
const fetchData = async () => {
setLoading(true)
try {
const r = await api.get('/nixserver/accounts/over-quota')
setItems(r.data.data)
} finally {
setLoading(false)
}
}
useEffect(() => { fetchData() }, [])
const statusColor: Record<string, string> = {
active: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
suspended: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
terminated: 'bg-red-500/10 text-red-400 border-red-500/20',
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm">
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
<ChevronLeft size={14} /> Accounts
</Link>
<span className="text-[#2a2d3e]">/</span>
<span className="text-white">Show Accounts Over Quota</span>
</div>
<div className="flex items-center justify-between">
<div>
<h1 className="text-white text-xl font-semibold">Show Accounts Over Quota</h1>
<p className="text-[#64748b] text-sm">{items.length} account{items.length !== 1 ? 's' : ''} over disk quota</p>
</div>
<button onClick={fetchData} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg transition-colors">
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
</button>
</div>
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#2a2d3e]">
{['Username', 'Domain', 'Package', 'Disk Used', 'Disk Limit', 'Over By', 'Usage', 'Status'].map(h => (
<th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>
))}
</tr>
</thead>
<tbody>
{loading && (
<tr><td colSpan={8} className="text-center text-[#64748b] py-8">Loading...</td></tr>
)}
{!loading && items.map(acc => {
const pct = Math.min(100, Math.round((acc.diskUsedMb / acc.package.diskMb) * 100))
const overBy = acc.diskUsedMb - acc.package.diskMb
return (
<tr key={acc.id} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130] transition-colors">
<td className="px-4 py-3">
<Link to={`/accounts/${acc.id}`} className="text-indigo-400 hover:text-indigo-300 font-medium">
{acc.username}
</Link>
</td>
<td className="px-4 py-3 text-[#94a3b8]">{acc.domain}</td>
<td className="px-4 py-3 text-[#94a3b8]">{acc.package.name}</td>
<td className="px-4 py-3 text-red-400 font-mono text-xs">{acc.diskUsedMb.toLocaleString()} MB</td>
<td className="px-4 py-3 text-[#94a3b8] font-mono text-xs">{acc.package.diskMb.toLocaleString()} MB</td>
<td className="px-4 py-3 text-red-400 font-mono text-xs font-semibold">+{overBy.toLocaleString()} MB</td>
<td className="px-4 py-3 min-w-[120px]">
<div className="flex items-center gap-2">
<div className="flex-1 bg-[#2a2d3e] rounded-full h-1.5 overflow-hidden">
<div
className="h-full bg-red-500 rounded-full transition-all"
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-red-400 text-xs font-mono">{pct}%</span>
</div>
</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded-full border ${statusColor[acc.status] ?? ''}`}>
{acc.status}
</span>
</td>
</tr>
)
})}
{!loading && items.length === 0 && (
<tr><td colSpan={8} className="text-center text-[#64748b] py-8">No accounts are over quota</td></tr>
)}
</tbody>
</table>
</div>
</div>
)
}
@@ -0,0 +1,92 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { ChevronLeft, RefreshCw } from 'lucide-react'
import { api } from '../../api/client'
interface ParkedDomain {
id: number
domain: string
accountId: number
status: string
createdAt: string
account: { username: string; domain: string }
}
export default function ParkedDomains() {
const [items, setItems] = useState<ParkedDomain[]>([])
const [loading, setLoading] = useState(false)
const fetchData = async () => {
setLoading(true)
try {
const r = await api.get('/nixserver/accounts/parked-domains')
setItems(r.data.data)
} finally {
setLoading(false)
}
}
useEffect(() => { fetchData() }, [])
const statusColor: Record<string, string> = {
active: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
inactive: 'bg-[#2a2d3e] text-[#64748b] border-[#2a2d3e]',
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm">
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
<ChevronLeft size={14} /> Accounts
</Link>
<span className="text-[#2a2d3e]">/</span>
<span className="text-white">List Parked Domains</span>
</div>
<div className="flex items-center justify-between">
<div>
<h1 className="text-white text-xl font-semibold">List Parked Domains</h1>
<p className="text-[#64748b] text-sm">{items.length} parked domain{items.length !== 1 ? 's' : ''}</p>
</div>
<button onClick={fetchData} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg transition-colors">
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
</button>
</div>
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#2a2d3e]">
{['Parked Domain', 'Primary Domain', 'Username', 'Status', 'Created'].map(h => (
<th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>
))}
</tr>
</thead>
<tbody>
{loading && (
<tr><td colSpan={5} className="text-center text-[#64748b] py-8">Loading...</td></tr>
)}
{!loading && items.map(pd => (
<tr key={pd.id} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130] transition-colors">
<td className="px-4 py-3 text-white font-medium">{pd.domain}</td>
<td className="px-4 py-3 text-[#94a3b8]">{pd.account.domain}</td>
<td className="px-4 py-3">
<span className="text-indigo-400">{pd.account.username}</span>
</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded-full border ${statusColor[pd.status] ?? 'text-[#94a3b8]'}`}>
{pd.status}
</span>
</td>
<td className="px-4 py-3 text-[#64748b] text-xs">{new Date(pd.createdAt).toLocaleDateString()}</td>
</tr>
))}
{!loading && items.length === 0 && (
<tr><td colSpan={5} className="text-center text-[#64748b] py-8">No parked domains found</td></tr>
)}
</tbody>
</table>
</div>
</div>
)
}
@@ -0,0 +1,102 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { ChevronLeft, KeyRound, Eye, EyeOff } from 'lucide-react'
import { api } from '../../api/client'
import AccountPicker, { type PickedAccount } from './AccountPicker'
export default function PasswordMod() {
const [selected, setSelected] = useState<PickedAccount | null>(null)
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [showPw, setShowPw] = useState(false)
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const handleSubmit = async () => {
if (!selected) return
if (password !== confirm) { setError('Passwords do not match'); return }
if (password.length < 8) { setError('Password must be at least 8 characters'); return }
setLoading(true); setMessage(''); setError('')
try {
await api.put(`/nixserver/accounts/${selected.id}`, { password })
setMessage(`Password for "${selected.username}" has been updated.`)
setPassword(''); setConfirm('')
} catch (e: unknown) {
setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed')
} finally {
setLoading(false)
}
}
return (
<div className="space-y-4 max-w-xl">
<div className="flex items-center gap-2 text-sm">
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
<ChevronLeft size={14} /> Accounts
</Link>
<span className="text-[#2a2d3e]">/</span>
<span className="text-white">Password Modification</span>
</div>
<div>
<h1 className="text-white text-xl font-semibold">Password Modification</h1>
<p className="text-[#64748b] text-sm">Change the password for a hosting account.</p>
</div>
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-5 space-y-4">
<AccountPicker onSelect={acc => { setSelected(acc); setPassword(''); setConfirm(''); setMessage(''); setError('') }} selected={selected} />
{selected && (
<div className="space-y-4 pt-2 border-t border-[#2a2d3e]">
<div className="space-y-2">
<label className="block text-[#94a3b8] text-sm font-medium">New Password</label>
<div className="relative">
<input
type={showPw ? 'text' : 'password'}
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="At least 8 characters"
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 pr-10 py-2 focus:outline-none focus:border-indigo-500"
/>
<button
type="button"
onClick={() => setShowPw(v => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-[#64748b] hover:text-white transition-colors"
>
{showPw ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
</div>
</div>
<div className="space-y-2">
<label className="block text-[#94a3b8] text-sm font-medium">Confirm Password</label>
<input
type={showPw ? 'text' : 'password'}
value={confirm}
onChange={e => setConfirm(e.target.value)}
placeholder="Re-enter password"
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500"
/>
</div>
<button
onClick={handleSubmit}
disabled={loading || !password || !confirm}
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-4 py-2 rounded-lg transition-colors disabled:opacity-50"
>
<KeyRound size={16} /> {loading ? 'Updating...' : 'Update Password'}
</button>
</div>
)}
{message && (
<p className="text-emerald-400 text-sm bg-emerald-500/10 border border-emerald-500/20 rounded-lg px-4 py-3">{message}</p>
)}
{error && (
<p className="text-red-400 text-sm bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">{error}</p>
)}
</div>
</div>
)
}
+129
View File
@@ -0,0 +1,129 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { ChevronLeft, HardDrive } from 'lucide-react'
import { api } from '../../api/client'
import AccountPicker, { type PickedAccount } from './AccountPicker'
export default function QuotaMod() {
const [selected, setSelected] = useState<PickedAccount | null>(null)
const [fullAccount, setFullAccount] = useState<{ diskUsedMb: number; package?: { diskMb: number; bandwidthMb: number } } | null>(null)
const [diskMb, setDiskMb] = useState('')
const [bandwidthMb, setBandwidthMb] = useState('')
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const handleSelect = async (acc: PickedAccount) => {
setSelected(acc)
setMessage(''); setError('')
try {
const r = await api.get(`/nixserver/accounts/${acc.id}`)
const data = r.data.data
setFullAccount(data)
setDiskMb(String(data.package?.diskMb ?? ''))
setBandwidthMb(String(data.package?.bandwidthMb ?? ''))
} catch {
setFullAccount(null)
setDiskMb('')
setBandwidthMb('')
}
}
const handleSubmit = async () => {
if (!selected) return
const d = parseInt(diskMb, 10)
const b = parseInt(bandwidthMb, 10)
if (!d || !b || d <= 0 || b <= 0) { setError('Enter valid positive values for both fields'); return }
setLoading(true); setMessage(''); setError('')
try {
await api.put(`/nixserver/accounts/${selected.id}/quota`, { diskMb: d, bandwidthMb: b })
setMessage(`Quota updated for "${selected.username}".`)
} catch (e: unknown) {
setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed')
} finally {
setLoading(false)
}
}
return (
<div className="space-y-4 max-w-xl">
<div className="flex items-center gap-2 text-sm">
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
<ChevronLeft size={14} /> Accounts
</Link>
<span className="text-[#2a2d3e]">/</span>
<span className="text-white">Quota Modification</span>
</div>
<div>
<h1 className="text-white text-xl font-semibold">Quota Modification</h1>
<p className="text-[#64748b] text-sm">Adjust disk and bandwidth quotas for a hosting account.</p>
</div>
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-5 space-y-4">
<AccountPicker onSelect={handleSelect} selected={selected} />
{selected && (
<div className="space-y-4 pt-2 border-t border-[#2a2d3e]">
{fullAccount && (
<div className="grid grid-cols-2 gap-3">
<div className="bg-[#0f1117] border border-[#2a2d3e] rounded-lg px-4 py-3">
<p className="text-[#64748b] text-xs mb-1">Disk Used</p>
<p className="text-white font-mono text-sm">{fullAccount.diskUsedMb?.toLocaleString() ?? 0} MB</p>
</div>
<div className="bg-[#0f1117] border border-[#2a2d3e] rounded-lg px-4 py-3">
<p className="text-[#64748b] text-xs mb-1">Current Package</p>
<p className="text-white text-sm">{selected.package?.name ?? 'None'}</p>
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="block text-[#94a3b8] text-sm font-medium">Disk Limit (MB)</label>
<input
type="number"
value={diskMb}
onChange={e => setDiskMb(e.target.value)}
min="1"
placeholder="e.g. 10240"
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500"
/>
</div>
<div className="space-y-2">
<label className="block text-[#94a3b8] text-sm font-medium">Bandwidth Limit (MB)</label>
<input
type="number"
value={bandwidthMb}
onChange={e => setBandwidthMb(e.target.value)}
min="1"
placeholder="e.g. 102400"
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
<p className="text-[#64748b] text-xs">
Note: This will update the quota on the account's associated package.
</p>
<button
onClick={handleSubmit}
disabled={loading || !diskMb || !bandwidthMb}
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-4 py-2 rounded-lg transition-colors disabled:opacity-50"
>
<HardDrive size={16} /> {loading ? 'Updating...' : 'Update Quota'}
</button>
</div>
)}
{message && (
<p className="text-emerald-400 text-sm bg-emerald-500/10 border border-emerald-500/20 rounded-lg px-4 py-3">{message}</p>
)}
{error && (
<p className="text-red-400 text-sm bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">{error}</p>
)}
</div>
</div>
)
}
@@ -0,0 +1,35 @@
import { Link } from 'react-router-dom'
import { ChevronLeft, ArrowLeftRight, Clock } from 'lucide-react'
export default function Rearrange() {
return (
<div className="space-y-4 max-w-xl">
<div className="flex items-center gap-2 text-sm">
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
<ChevronLeft size={14} /> Accounts
</Link>
<span className="text-[#2a2d3e]">/</span>
<span className="text-white">Rearrange an Account</span>
</div>
<div>
<h1 className="text-white text-xl font-semibold">Rearrange an Account</h1>
<p className="text-[#64748b] text-sm">Move an account between resellers or change ownership.</p>
</div>
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-8 flex flex-col items-center gap-4 text-center">
<div className="w-14 h-14 rounded-full bg-orange-500/10 flex items-center justify-center">
<ArrowLeftRight size={28} className="text-orange-400" />
</div>
<div>
<div className="flex items-center justify-center gap-2 mb-2">
<Clock size={14} className="text-orange-400" />
<span className="text-orange-400 text-sm font-medium">Coming Soon</span>
</div>
<p className="text-white font-semibold text-lg mb-1">Rearrange an Account</p>
<p className="text-[#64748b] text-sm max-w-sm">
This feature will allow you to move hosting accounts between resellers or reassign ownership to a different reseller.
</p>
</div>
</div>
</div>
)
}
@@ -0,0 +1,136 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { ChevronLeft, RefreshCw, RotateCcw } from 'lucide-react'
import { api } from '../../api/client'
interface BandwidthAccount {
id: number
username: string
domain: string
status: string
bandwidthUsedMb: number
package?: { name: string; bandwidthMb: number }
}
export default function ResetBandwidth() {
const [items, setItems] = useState<BandwidthAccount[]>([])
const [loading, setLoading] = useState(false)
const [resetting, setResetting] = useState<Set<number>>(new Set())
const [resetAll, setResetAll] = useState(false)
const fetchData = async () => {
setLoading(true)
try {
const r = await api.get('/nixserver/accounts/bandwidth')
setItems(r.data.data)
} finally {
setLoading(false)
}
}
useEffect(() => { fetchData() }, [])
const handleReset = async (id: number) => {
setResetting(prev => new Set(prev).add(id))
try {
await api.post(`/nixserver/accounts/${id}/reset-bandwidth`)
setItems(prev => prev.map(a => a.id === id ? { ...a, bandwidthUsedMb: 0 } : a))
} catch (e: unknown) {
alert((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Reset failed')
} finally {
setResetting(prev => { const s = new Set(prev); s.delete(id); return s })
}
}
const handleResetAll = async () => {
if (!confirm(`Reset bandwidth for all ${items.length} accounts?`)) return
setResetAll(true)
try {
await Promise.all(items.map(a => api.post(`/nixserver/accounts/${a.id}/reset-bandwidth`)))
setItems(prev => prev.map(a => ({ ...a, bandwidthUsedMb: 0 })))
} catch (e: unknown) {
alert((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Bulk reset failed')
} finally {
setResetAll(false)
}
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm">
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
<ChevronLeft size={14} /> Accounts
</Link>
<span className="text-[#2a2d3e]">/</span>
<span className="text-white">Reset Account Bandwidth Limit</span>
</div>
<div className="flex items-center justify-between">
<div>
<h1 className="text-white text-xl font-semibold">Reset Account Bandwidth Limit</h1>
<p className="text-[#64748b] text-sm">Reset bandwidth counters for individual accounts or all at once.</p>
</div>
<div className="flex gap-2">
<button onClick={fetchData} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg transition-colors">
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
</button>
<button
onClick={handleResetAll}
disabled={resetAll || items.length === 0}
className="flex items-center gap-2 bg-amber-600/20 hover:bg-amber-600/30 text-amber-400 border border-amber-500/30 text-sm px-3 py-2 rounded-lg transition-colors disabled:opacity-50"
>
<RotateCcw size={15} /> {resetAll ? 'Resetting...' : 'Reset All'}
</button>
</div>
</div>
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#2a2d3e]">
{['Username', 'Domain', 'Bandwidth Used', 'Limit', 'Status', ''].map(h => (
<th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>
))}
</tr>
</thead>
<tbody>
{loading && (
<tr><td colSpan={6} className="text-center text-[#64748b] py-8">Loading...</td></tr>
)}
{!loading && items.map(acc => (
<tr key={acc.id} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130] transition-colors">
<td className="px-4 py-3 text-white font-medium">{acc.username}</td>
<td className="px-4 py-3 text-[#94a3b8]">{acc.domain}</td>
<td className="px-4 py-3 text-[#94a3b8] font-mono text-xs">{acc.bandwidthUsedMb.toLocaleString()} MB</td>
<td className="px-4 py-3 text-[#94a3b8] font-mono text-xs">
{acc.package?.bandwidthMb ? `${acc.package.bandwidthMb.toLocaleString()} MB` : '—'}
</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded-full border ${
acc.status === 'active' ? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' :
acc.status === 'suspended' ? 'bg-amber-500/10 text-amber-400 border-amber-500/20' :
'bg-red-500/10 text-red-400 border-red-500/20'
}`}>
{acc.status}
</span>
</td>
<td className="px-4 py-3">
<button
onClick={() => handleReset(acc.id)}
disabled={resetting.has(acc.id) || acc.bandwidthUsedMb === 0}
className="flex items-center gap-1.5 text-xs bg-indigo-600/10 hover:bg-indigo-600/20 text-indigo-400 border border-indigo-500/20 px-3 py-1.5 rounded-lg transition-colors disabled:opacity-40"
>
<RotateCcw size={12} /> {resetting.has(acc.id) ? 'Resetting...' : 'Reset'}
</button>
</td>
</tr>
))}
{!loading && items.length === 0 && (
<tr><td colSpan={6} className="text-center text-[#64748b] py-8">No accounts found</td></tr>
)}
</tbody>
</table>
</div>
</div>
)
}
@@ -0,0 +1,112 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { ChevronLeft, Terminal, ShieldCheck, ShieldOff } from 'lucide-react'
import { api } from '../../api/client'
import AccountPicker, { type PickedAccount } from './AccountPicker'
export default function ShellAccess() {
const [selected, setSelected] = useState<PickedAccount | null>(null)
const [shell, setShell] = useState<string>('/usr/sbin/nologin')
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const handleSelect = async (acc: PickedAccount) => {
setSelected(acc); setMessage(''); setError('')
try {
const r = await api.get(`/nixserver/accounts/${acc.id}`)
setShell(r.data.data.shell ?? '/usr/sbin/nologin')
} catch {
setShell('/usr/sbin/nologin')
}
}
const toggle = async (enable: boolean) => {
if (!selected) return
setLoading(true); setMessage(''); setError('')
try {
const r = await api.put(`/nixserver/accounts/${selected.id}/shell`, { enabled: enable })
setShell(r.data.data.shell)
setMessage(enable
? `Shell access enabled for "${selected.username}".`
: `Shell access disabled for "${selected.username}".`)
} catch (e: unknown) {
setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed')
} finally { setLoading(false) }
}
const hasShell = shell === '/bin/bash' || shell === '/bin/sh'
return (
<div className="space-y-4 max-w-xl">
<div className="flex items-center gap-2 text-sm">
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
<ChevronLeft size={14} /> Accounts
</Link>
<span className="text-[#2a2d3e]">/</span>
<span className="text-white">Manage Shell Access</span>
</div>
<div>
<h1 className="text-white text-xl font-semibold">Manage Shell Access</h1>
<p className="text-[#64748b] text-sm">Grant or revoke SSH shell access for hosting accounts.</p>
</div>
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-5 space-y-4">
<AccountPicker onSelect={handleSelect} selected={selected} />
{selected && (
<div className="space-y-4 pt-2 border-t border-[#2a2d3e]">
<div className="flex items-center gap-3 bg-[#0f1117] border border-[#2a2d3e] rounded-lg px-4 py-3">
<Terminal size={16} className={hasShell ? 'text-emerald-400' : 'text-[#64748b]'} />
<div className="flex-1">
<p className="text-white text-sm font-medium">{selected.username}</p>
<p className="text-[#64748b] text-xs font-mono mt-0.5">shell: {shell}</p>
</div>
<span className={`text-xs px-2 py-0.5 rounded-full border ${hasShell
? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20'
: 'bg-[#1a2235] text-[#64748b] border-[#2a2d3e]'}`}>
{hasShell ? 'Shell enabled' : 'No shell'}
</span>
</div>
{hasShell ? (
<div className="space-y-3">
<div className="bg-amber-500/10 border border-amber-500/20 rounded-lg px-4 py-3 text-amber-400 text-xs">
This account has SSH access via <span className="font-mono">{shell}</span>. Disabling sets shell to <span className="font-mono">/usr/sbin/nologin</span>.
</div>
<button
onClick={() => toggle(false)}
disabled={loading}
className="flex items-center gap-2 bg-amber-600/20 hover:bg-amber-600/30 text-amber-400 border border-amber-500/30 text-sm px-4 py-2 rounded-lg transition-colors disabled:opacity-50"
>
<ShieldOff size={16} /> {loading ? 'Disabling...' : 'Disable Shell Access'}
</button>
</div>
) : (
<div className="space-y-3">
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-lg px-4 py-3 text-[#64748b] text-xs">
No shell access. Enabling will set the shell to <span className="font-mono">/bin/bash</span>.
</div>
<button
onClick={() => toggle(true)}
disabled={loading}
className="flex items-center gap-2 bg-emerald-600/20 hover:bg-emerald-600/30 text-emerald-400 border border-emerald-500/30 text-sm px-4 py-2 rounded-lg transition-colors disabled:opacity-50"
>
<ShieldCheck size={16} /> {loading ? 'Enabling...' : 'Enable Shell Access'}
</button>
</div>
)}
</div>
)}
{message && (
<p className="text-emerald-400 text-sm bg-emerald-500/10 border border-emerald-500/20 rounded-lg px-4 py-3">{message}</p>
)}
{error && (
<p className="text-red-400 text-sm bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">{error}</p>
)}
</div>
</div>
)
}
@@ -0,0 +1,95 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { ChevronLeft, RefreshCw } from 'lucide-react'
import { api } from '../../api/client'
interface SubdomainItem {
id: number
subdomain: string
domain: string
accountId: number
documentRoot: string
status: string
createdAt: string
account: { username: string }
}
export default function Subdomains() {
const [items, setItems] = useState<SubdomainItem[]>([])
const [loading, setLoading] = useState(false)
const fetchData = async () => {
setLoading(true)
try {
const r = await api.get('/nixserver/accounts/subdomains')
setItems(r.data.data)
} finally {
setLoading(false)
}
}
useEffect(() => { fetchData() }, [])
const statusColor: Record<string, string> = {
active: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
inactive: 'bg-[#2a2d3e] text-[#64748b] border-[#2a2d3e]',
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm">
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
<ChevronLeft size={14} /> Accounts
</Link>
<span className="text-[#2a2d3e]">/</span>
<span className="text-white">List Subdomains</span>
</div>
<div className="flex items-center justify-between">
<div>
<h1 className="text-white text-xl font-semibold">List Subdomains</h1>
<p className="text-[#64748b] text-sm">{items.length} subdomain{items.length !== 1 ? 's' : ''}</p>
</div>
<button onClick={fetchData} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg transition-colors">
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
</button>
</div>
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#2a2d3e]">
{['Subdomain', 'Full Domain', 'Username', 'Document Root', 'Status', 'Created'].map(h => (
<th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>
))}
</tr>
</thead>
<tbody>
{loading && (
<tr><td colSpan={6} className="text-center text-[#64748b] py-8">Loading...</td></tr>
)}
{!loading && items.map(sd => (
<tr key={sd.id} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130] transition-colors">
<td className="px-4 py-3 text-white font-medium">{sd.subdomain}</td>
<td className="px-4 py-3 text-[#94a3b8]">{sd.domain}</td>
<td className="px-4 py-3">
<span className="text-indigo-400">{sd.account.username}</span>
</td>
<td className="px-4 py-3 text-[#64748b] font-mono text-xs truncate max-w-[200px]">{sd.documentRoot}</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded-full border ${statusColor[sd.status] ?? 'text-[#94a3b8]'}`}>
{sd.status}
</span>
</td>
<td className="px-4 py-3 text-[#64748b] text-xs">{new Date(sd.createdAt).toLocaleDateString()}</td>
</tr>
))}
{!loading && items.length === 0 && (
<tr><td colSpan={6} className="text-center text-[#64748b] py-8">No subdomains found</td></tr>
)}
</tbody>
</table>
</div>
</div>
)
}
+157
View File
@@ -0,0 +1,157 @@
import { useEffect, useState, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { Search, UserCheck, RefreshCw, ChevronLeft } from 'lucide-react'
import { api } from '../../api/client'
interface Account {
id: number
username: string
domain: string
email: string
status: string
suspendReason: string | null
suspendedAt: string | null
createdAt: string
package?: { name: string }
}
interface PageData {
items: Account[]
total: number
page: number
totalPages: number
}
export default function SuspendedAccounts() {
const [data, setData] = useState<PageData | null>(null)
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(false)
const fetchData = useCallback(async () => {
setLoading(true)
try {
const params = new URLSearchParams({ page: String(page), limit: '25', status: 'suspended' })
if (search) params.set('search', search)
const r = await api.get(`/nixserver/accounts?${params}`)
setData(r.data.data)
} finally {
setLoading(false)
}
}, [page, search])
useEffect(() => { fetchData() }, [fetchData])
const unsuspend = async (id: number) => {
try {
await api.post(`/nixserver/accounts/${id}/unsuspend`)
fetchData()
} catch (e: unknown) {
alert((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed')
}
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm">
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
<ChevronLeft size={14} /> Accounts
</Link>
<span className="text-[#2a2d3e]">/</span>
<span className="text-white">List Suspended Accounts</span>
</div>
<div>
<h1 className="text-white text-xl font-semibold">List Suspended Accounts</h1>
<p className="text-[#64748b] text-sm">{data?.total ?? 0} suspended accounts</p>
</div>
<div className="flex gap-3">
<div className="relative flex-1 max-w-sm">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#64748b]" />
<input
value={search}
onChange={e => { setSearch(e.target.value); setPage(1) }}
placeholder="Search username, domain, email..."
className="w-full bg-[#1a1d27] border border-[#2a2d3e] text-white text-sm rounded-lg pl-9 pr-3 py-2 focus:outline-none focus:border-indigo-500"
/>
</div>
<button onClick={fetchData} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg transition-colors">
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
</button>
</div>
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#2a2d3e]">
{['Username', 'Domain', 'Package', 'Suspend Reason', 'Suspended At', ''].map(h => (
<th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>
))}
</tr>
</thead>
<tbody>
{loading && !data && (
<tr><td colSpan={6} className="text-center text-[#64748b] py-8">Loading...</td></tr>
)}
{data?.items.map(acc => (
<tr key={acc.id} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130] transition-colors">
<td className="px-4 py-3">
<Link to={`/accounts/${acc.id}`} className="text-indigo-400 hover:text-indigo-300 font-medium">
{acc.username}
</Link>
<p className="text-[#64748b] text-xs">{acc.email}</p>
</td>
<td className="px-4 py-3 text-[#94a3b8]">{acc.domain}</td>
<td className="px-4 py-3 text-[#94a3b8]">{acc.package?.name ?? '—'}</td>
<td className="px-4 py-3">
<span className="text-amber-400 text-xs">
{acc.suspendReason ?? 'No reason given'}
</span>
</td>
<td className="px-4 py-3 text-[#64748b] text-xs">
{acc.suspendedAt ? new Date(acc.suspendedAt).toLocaleDateString() : '—'}
</td>
<td className="px-4 py-3">
<button
onClick={() => unsuspend(acc.id)}
className="flex items-center gap-1.5 text-xs bg-emerald-600/10 hover:bg-emerald-600/20 text-emerald-400 border border-emerald-500/20 px-3 py-1.5 rounded-lg transition-colors"
>
<UserCheck size={13} /> Unsuspend
</button>
</td>
</tr>
))}
{data?.items.length === 0 && (
<tr><td colSpan={6} className="text-center text-[#64748b] py-8">No suspended accounts</td></tr>
)}
</tbody>
</table>
{data && data.totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-[#2a2d3e]">
<span className="text-[#64748b] text-xs">
Page {data.page} of {data.totalPages} ({data.total} total)
</span>
<div className="flex gap-2">
<button
onClick={() => setPage(p => p - 1)}
disabled={page === 1}
className="px-3 py-1 text-xs bg-[#1e2130] border border-[#2a2d3e] rounded text-[#94a3b8] disabled:opacity-40 hover:border-indigo-500 transition-colors"
>
Prev
</button>
<button
onClick={() => setPage(p => p + 1)}
disabled={page >= data.totalPages}
className="px-3 py-1 text-xs bg-[#1e2130] border border-[#2a2d3e] rounded text-[#94a3b8] disabled:opacity-40 hover:border-indigo-500 transition-colors"
>
Next
</button>
</div>
</div>
)}
</div>
</div>
)
}
+108
View File
@@ -0,0 +1,108 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { ChevronLeft, UserX, UserCheck } from 'lucide-react'
import { api } from '../../api/client'
import AccountPicker, { type PickedAccount } from './AccountPicker'
export default function Suspension() {
const [selected, setSelected] = useState<PickedAccount | null>(null)
const [reason, setReason] = useState('')
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const handleSuspend = async () => {
if (!selected) return
setLoading(true); setMessage(''); setError('')
try {
await api.post(`/nixserver/accounts/${selected.id}/suspend`, { reason: reason || undefined })
setMessage(`Account "${selected.username}" has been suspended.`)
setSelected(prev => prev ? { ...prev, status: 'suspended' } : null)
} catch (e: unknown) {
setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed')
} finally {
setLoading(false)
}
}
const handleUnsuspend = async () => {
if (!selected) return
setLoading(true); setMessage(''); setError('')
try {
await api.post(`/nixserver/accounts/${selected.id}/unsuspend`)
setMessage(`Account "${selected.username}" has been unsuspended.`)
setSelected(prev => prev ? { ...prev, status: 'active' } : null)
} catch (e: unknown) {
setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed')
} finally {
setLoading(false)
}
}
return (
<div className="space-y-4 max-w-xl">
<div className="flex items-center gap-2 text-sm">
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
<ChevronLeft size={14} /> Accounts
</Link>
<span className="text-[#2a2d3e]">/</span>
<span className="text-white">Manage Account Suspension</span>
</div>
<div>
<h1 className="text-white text-xl font-semibold">Manage Account Suspension</h1>
<p className="text-[#64748b] text-sm">Search for an account to suspend or unsuspend it.</p>
</div>
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-5 space-y-4">
<AccountPicker onSelect={setSelected} selected={selected} />
{selected && (
<div className="space-y-4 pt-2 border-t border-[#2a2d3e]">
{selected.status === 'active' && (
<div className="space-y-3">
<label className="block text-[#94a3b8] text-sm font-medium">Suspend Reason (optional)</label>
<input
value={reason}
onChange={e => setReason(e.target.value)}
placeholder="e.g. TOS violation, non-payment..."
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500"
/>
<button
onClick={handleSuspend}
disabled={loading}
className="flex items-center gap-2 bg-amber-600/20 hover:bg-amber-600/30 text-amber-400 border border-amber-500/30 text-sm px-4 py-2 rounded-lg transition-colors disabled:opacity-50"
>
<UserX size={16} /> {loading ? 'Suspending...' : 'Suspend Account'}
</button>
</div>
)}
{selected.status === 'suspended' && (
<button
onClick={handleUnsuspend}
disabled={loading}
className="flex items-center gap-2 bg-emerald-600/20 hover:bg-emerald-600/30 text-emerald-400 border border-emerald-500/30 text-sm px-4 py-2 rounded-lg transition-colors disabled:opacity-50"
>
<UserCheck size={16} /> {loading ? 'Unsuspending...' : 'Unsuspend Account'}
</button>
)}
{selected.status !== 'active' && selected.status !== 'suspended' && (
<p className="text-[#64748b] text-sm">
This account has status <strong className="text-[#94a3b8]">{selected.status}</strong> and cannot be suspended/unsuspended.
</p>
)}
</div>
)}
{message && (
<p className="text-emerald-400 text-sm bg-emerald-500/10 border border-emerald-500/20 rounded-lg px-4 py-3">{message}</p>
)}
{error && (
<p className="text-red-400 text-sm bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">{error}</p>
)}
</div>
</div>
)
}
+103
View File
@@ -0,0 +1,103 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { ChevronLeft, Trash2, AlertTriangle } from 'lucide-react'
import { api } from '../../api/client'
import AccountPicker, { type PickedAccount } from './AccountPicker'
export default function Terminate() {
const [selected, setSelected] = useState<PickedAccount | null>(null)
const [confirm, setConfirm] = useState('')
const [removeFiles, setRemoveFiles] = useState(false)
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const handleTerminate = async () => {
if (!selected) return
setLoading(true); setMessage(''); setError('')
try {
await api.delete(`/nixserver/accounts/${selected.id}`, { data: { removeFiles } })
setMessage(`Account "${selected.username}" has been terminated.`)
setSelected(null)
setConfirm('')
} catch (e: unknown) {
setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed')
} finally {
setLoading(false)
}
}
const canTerminate = selected && confirm === selected.username
return (
<div className="space-y-4 max-w-xl">
<div className="flex items-center gap-2 text-sm">
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
<ChevronLeft size={14} /> Accounts
</Link>
<span className="text-[#2a2d3e]">/</span>
<span className="text-white">Terminate Accounts</span>
</div>
<div>
<h1 className="text-white text-xl font-semibold">Terminate Accounts</h1>
<p className="text-[#64748b] text-sm">Permanently terminate a hosting account. This action cannot be undone.</p>
</div>
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-5 space-y-4">
<AccountPicker onSelect={acc => { setSelected(acc); setConfirm('') }} selected={selected} />
{selected && (
<div className="space-y-4 pt-2 border-t border-[#2a2d3e]">
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4 flex items-start gap-3">
<AlertTriangle size={18} className="text-red-400 flex-shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-red-400 text-sm font-medium">Warning: Irreversible Action</p>
<p className="text-[#94a3b8] text-xs">
Terminating <strong>{selected.username}</strong> will permanently delete the account, DNS zones, email accounts, databases, and SSL certificates.
</p>
</div>
</div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={removeFiles}
onChange={e => setRemoveFiles(e.target.checked)}
className="w-4 h-4 rounded border-[#2a2d3e] bg-[#0f1117] accent-red-500"
/>
<span className="text-[#94a3b8] text-sm">Also delete home directory files</span>
</label>
<div className="space-y-2">
<label className="block text-[#94a3b8] text-sm">
Type <strong className="text-white">{selected.username}</strong> to confirm
</label>
<input
value={confirm}
onChange={e => setConfirm(e.target.value)}
placeholder={selected.username}
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-red-500"
/>
</div>
<button
onClick={handleTerminate}
disabled={!canTerminate || loading}
className="flex items-center gap-2 bg-red-600/20 hover:bg-red-600/30 text-red-400 border border-red-500/30 text-sm px-4 py-2 rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<Trash2 size={16} /> {loading ? 'Terminating...' : 'Terminate Account'}
</button>
</div>
)}
{message && (
<p className="text-emerald-400 text-sm bg-emerald-500/10 border border-emerald-500/20 rounded-lg px-4 py-3">{message}</p>
)}
{error && (
<p className="text-red-400 text-sm bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">{error}</p>
)}
</div>
</div>
)
}
@@ -0,0 +1,142 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { ChevronLeft, RefreshCw, UserCheck } from 'lucide-react'
import { api } from '../../api/client'
interface BandwidthAccount {
id: number
username: string
domain: string
status: string
bandwidthUsedMb: number
suspendReason: string | null
package?: { name: string; bandwidthMb: number }
}
export default function UnsuspendBandwidth() {
const [items, setItems] = useState<BandwidthAccount[]>([])
const [loading, setLoading] = useState(false)
const [unsuspending, setUnsuspending] = useState<Set<number>>(new Set())
const [bulkLoading, setBulkLoading] = useState(false)
const [message, setMessage] = useState('')
const fetchData = async () => {
setLoading(true)
try {
const r = await api.get('/nixserver/accounts/bandwidth')
const all: BandwidthAccount[] = r.data.data
setItems(all.filter(a =>
a.status === 'suspended' &&
a.suspendReason?.toLowerCase().includes('bandwidth')
))
} finally {
setLoading(false)
}
}
useEffect(() => { fetchData() }, [])
const handleUnsuspend = async (id: number, username: string) => {
setUnsuspending(prev => new Set(prev).add(id))
try {
await api.post(`/nixserver/accounts/${id}/unsuspend`)
setItems(prev => prev.filter(a => a.id !== id))
} catch (e: unknown) {
alert((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed')
} finally {
setUnsuspending(prev => { const s = new Set(prev); s.delete(id); return s })
void username
}
}
const handleUnsuspendAll = async () => {
if (!confirm(`Unsuspend all ${items.length} bandwidth-exceeded accounts?`)) return
setBulkLoading(true)
try {
const r = await api.post('/nixserver/accounts/unsuspend-bandwidth-exceeded')
const results = r.data.data as { username: string; success: boolean }[]
const count = results.filter(x => x.success).length
setMessage(`Successfully unsuspended ${count} account${count !== 1 ? 's' : ''}.`)
await fetchData()
} catch (e: unknown) {
alert((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Bulk unsuspend failed')
} finally {
setBulkLoading(false)
}
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm">
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
<ChevronLeft size={14} /> Accounts
</Link>
<span className="text-[#2a2d3e]">/</span>
<span className="text-white">Unsuspend Bandwidth Exceeders</span>
</div>
<div className="flex items-center justify-between">
<div>
<h1 className="text-white text-xl font-semibold">Unsuspend Bandwidth Exceeders</h1>
<p className="text-[#64748b] text-sm">{items.length} account{items.length !== 1 ? 's' : ''} suspended for bandwidth overage</p>
</div>
<div className="flex gap-2">
<button onClick={fetchData} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg transition-colors">
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
</button>
<button
onClick={handleUnsuspendAll}
disabled={bulkLoading || items.length === 0}
className="flex items-center gap-2 bg-emerald-600/20 hover:bg-emerald-600/30 text-emerald-400 border border-emerald-500/30 text-sm px-3 py-2 rounded-lg transition-colors disabled:opacity-50"
>
<UserCheck size={15} /> {bulkLoading ? 'Unsuspending...' : 'Unsuspend All'}
</button>
</div>
</div>
{message && (
<p className="text-emerald-400 text-sm bg-emerald-500/10 border border-emerald-500/20 rounded-lg px-4 py-3">{message}</p>
)}
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#2a2d3e]">
{['Username', 'Domain', 'Bandwidth Used', 'Limit', 'Suspend Reason', ''].map(h => (
<th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>
))}
</tr>
</thead>
<tbody>
{loading && (
<tr><td colSpan={6} className="text-center text-[#64748b] py-8">Loading...</td></tr>
)}
{!loading && items.map(acc => (
<tr key={acc.id} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130] transition-colors">
<td className="px-4 py-3 text-white font-medium">{acc.username}</td>
<td className="px-4 py-3 text-[#94a3b8]">{acc.domain}</td>
<td className="px-4 py-3 text-amber-400 font-mono text-xs">{acc.bandwidthUsedMb.toLocaleString()} MB</td>
<td className="px-4 py-3 text-[#94a3b8] font-mono text-xs">
{acc.package?.bandwidthMb ? `${acc.package.bandwidthMb.toLocaleString()} MB` : '—'}
</td>
<td className="px-4 py-3 text-amber-400 text-xs">{acc.suspendReason ?? '—'}</td>
<td className="px-4 py-3">
<button
onClick={() => handleUnsuspend(acc.id, acc.username)}
disabled={unsuspending.has(acc.id)}
className="flex items-center gap-1.5 text-xs bg-emerald-600/10 hover:bg-emerald-600/20 text-emerald-400 border border-emerald-500/20 px-3 py-1.5 rounded-lg transition-colors disabled:opacity-50"
>
<UserCheck size={12} /> {unsuspending.has(acc.id) ? 'Unsuspending...' : 'Unsuspend'}
</button>
</td>
</tr>
))}
{!loading && items.length === 0 && (
<tr><td colSpan={6} className="text-center text-[#64748b] py-8">No bandwidth-exceeded suspended accounts</td></tr>
)}
</tbody>
</table>
</div>
</div>
)
}
+108
View File
@@ -0,0 +1,108 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { ChevronLeft, ArrowUpDown } from 'lucide-react'
import { api } from '../../api/client'
import AccountPicker, { type PickedAccount } from './AccountPicker'
interface Package {
id: number
name: string
diskMb: number
bandwidthMb: number
}
export default function Upgrade() {
const [selected, setSelected] = useState<PickedAccount | null>(null)
const [packageId, setPackageId] = useState<string>('')
const [packages, setPackages] = useState<Package[]>([])
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
const [error, setError] = useState('')
useEffect(() => {
api.get('/nixserver/packages').then(r => setPackages(r.data.data?.items ?? r.data.data ?? [])).catch(() => {})
}, [])
const handleSelect = (acc: PickedAccount) => {
setSelected(acc)
setPackageId('')
setMessage('')
setError('')
}
const handleSubmit = async () => {
if (!selected || !packageId) return
setLoading(true); setMessage(''); setError('')
try {
await api.put(`/nixserver/accounts/${selected.id}`, { packageId: parseInt(packageId, 10) })
const pkg = packages.find(p => p.id === parseInt(packageId, 10))
setMessage(`Package for "${selected.username}" changed to "${pkg?.name ?? packageId}".`)
if (pkg) setSelected(prev => prev ? { ...prev, package: { name: pkg.name } } : null)
} catch (e: unknown) {
setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed')
} finally {
setLoading(false)
}
}
return (
<div className="space-y-4 max-w-xl">
<div className="flex items-center gap-2 text-sm">
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
<ChevronLeft size={14} /> Accounts
</Link>
<span className="text-[#2a2d3e]">/</span>
<span className="text-white">Upgrade / Downgrade an Account</span>
</div>
<div>
<h1 className="text-white text-xl font-semibold">Upgrade / Downgrade an Account</h1>
<p className="text-[#64748b] text-sm">Change the hosting package assigned to an account.</p>
</div>
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-5 space-y-4">
<AccountPicker onSelect={handleSelect} selected={selected} />
{selected && (
<div className="space-y-4 pt-2 border-t border-[#2a2d3e]">
<div className="bg-[#0f1117] border border-[#2a2d3e] rounded-lg px-4 py-3">
<p className="text-[#64748b] text-xs mb-1">Current Package</p>
<p className="text-white font-medium">{selected.package?.name ?? 'No package assigned'}</p>
</div>
<div className="space-y-2">
<label className="block text-[#94a3b8] text-sm font-medium">New Package</label>
<select
value={packageId}
onChange={e => setPackageId(e.target.value)}
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-[#94a3b8] text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500"
>
<option value="">— Select a package —</option>
{packages.map(p => (
<option key={p.id} value={p.id}>
{p.name} &mdash; {p.diskMb.toLocaleString()} MB disk / {p.bandwidthMb.toLocaleString()} MB BW
</option>
))}
</select>
</div>
<button
onClick={handleSubmit}
disabled={loading || !packageId}
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-4 py-2 rounded-lg transition-colors disabled:opacity-50"
>
<ArrowUpDown size={16} /> {loading ? 'Applying...' : 'Apply Package Change'}
</button>
</div>
)}
{message && (
<p className="text-emerald-400 text-sm bg-emerald-500/10 border border-emerald-500/20 rounded-lg px-4 py-3">{message}</p>
)}
{error && (
<p className="text-red-400 text-sm bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">{error}</p>
)}
</div>
</div>
)
}
@@ -0,0 +1,35 @@
import { Link } from 'react-router-dom'
import { ChevronLeft, Code2, Clock } from 'lucide-react'
export default function WebTemplate() {
return (
<div className="space-y-4 max-w-xl">
<div className="flex items-center gap-2 text-sm">
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
<ChevronLeft size={14} /> Accounts
</Link>
<span className="text-[#2a2d3e]">/</span>
<span className="text-white">Web Template Editor</span>
</div>
<div>
<h1 className="text-white text-xl font-semibold">Web Template Editor</h1>
<p className="text-[#64748b] text-sm">Edit the default HTML templates served to new accounts.</p>
</div>
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-8 flex flex-col items-center gap-4 text-center">
<div className="w-14 h-14 rounded-full bg-orange-500/10 flex items-center justify-center">
<Code2 size={28} className="text-orange-400" />
</div>
<div>
<div className="flex items-center justify-center gap-2 mb-2">
<Clock size={14} className="text-orange-400" />
<span className="text-orange-400 text-sm font-medium">Coming Soon</span>
</div>
<p className="text-white font-semibold text-lg mb-1">Web Template Editor</p>
<p className="text-[#64748b] text-sm max-w-sm">
This feature will allow you to edit the default HTML templates and placeholder pages that are served to newly created hosting accounts.
</p>
</div>
</div>
</div>
)
}