Files
yakpanel-core/YakPanel-server/frontend/src/pages/FtpPage.tsx
2026-04-07 13:23:35 +05:30

287 lines
10 KiB
TypeScript

import { useEffect, useState } from 'react'
import Modal from 'react-bootstrap/Modal'
import { apiRequest, updateFtpPassword } from '../api/client'
import { PageHeader, AdminButton, AdminAlert, AdminTable, EmptyState } from '../components/admin'
interface FtpAccount {
id: number
name: string
path: string
ps: string
}
export function FtpPage() {
const [accounts, setAccounts] = useState<FtpAccount[]>([])
const [logPath, setLogPath] = useState<string | null>(null)
const [logContent, setLogContent] = useState('')
const [logLoading, setLogLoading] = useState(false)
const [logError, setLogError] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [showCreate, setShowCreate] = useState(false)
const [creating, setCreating] = useState(false)
const [creatingError, setCreatingError] = useState('')
const [changePwId, setChangePwId] = useState<number | null>(null)
const [pwError, setPwError] = useState('')
const loadAccounts = () => {
setLoading(true)
apiRequest<FtpAccount[]>('/ftp/list')
.then(setAccounts)
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}
useEffect(() => {
loadAccounts()
}, [])
const loadFtpLogs = () => {
setLogLoading(true)
setLogError('')
apiRequest<{ path: string | null; content: string }>('/ftp/logs?lines=400')
.then((r) => {
setLogPath(r.path)
setLogContent(r.content || '')
})
.catch((err) => setLogError(err.message))
.finally(() => setLogLoading(false))
}
const handleCreate = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const form = e.currentTarget
const name = (form.elements.namedItem('name') as HTMLInputElement).value.trim()
const password = (form.elements.namedItem('password') as HTMLInputElement).value
const path = (form.elements.namedItem('path') as HTMLInputElement).value.trim()
const ps = (form.elements.namedItem('ps') as HTMLInputElement).value.trim()
if (!name || !password || !path) {
setCreatingError('Name, password and path are required')
return
}
setCreating(true)
setCreatingError('')
apiRequest<{ status: boolean; msg: string }>('/ftp/create', {
method: 'POST',
body: JSON.stringify({ name, password, path, ps }),
})
.then(() => {
setShowCreate(false)
form.reset()
loadAccounts()
})
.catch((err) => setCreatingError(err.message))
.finally(() => setCreating(false))
}
const handleChangePassword = (e: React.FormEvent<HTMLFormElement>, id: number) => {
e.preventDefault()
const form = e.currentTarget
const password = (form.elements.namedItem('new_password') as HTMLInputElement).value
const confirm = (form.elements.namedItem('confirm_password') as HTMLInputElement).value
if (!password || password.length < 6) {
setPwError('Password must be at least 6 characters')
return
}
if (password !== confirm) {
setPwError('Passwords do not match')
return
}
setPwError('')
updateFtpPassword(id, password)
.then(() => setChangePwId(null))
.catch((err) => setPwError(err.message))
}
const handleDelete = (id: number, name: string) => {
if (!confirm(`Delete FTP account "${name}"?`)) return
apiRequest<{ status: boolean }>(`/ftp/${id}`, { method: 'DELETE' })
.then(loadAccounts)
.catch((err) => setError(err.message))
}
if (loading) {
return (
<>
<PageHeader title="FTP" />
<div className="text-center py-5 text-muted">Loading</div>
</>
)
}
if (error) {
return (
<>
<PageHeader title="FTP" />
<AdminAlert variant="danger">{error}</AdminAlert>
</>
)
}
return (
<>
<PageHeader
title="FTP"
actions={
<AdminButton onClick={() => setShowCreate(true)}>
<i className="ti ti-plus me-1" />
Add FTP
</AdminButton>
}
/>
<div className="alert alert-secondary" role="note">
FTP accounts use Pure-FTPd (pure-pw). Path must be under www root. Install:{' '}
<code>apt install pure-ftpd pure-ftpd-common</code>
</div>
<div className="card shadow-sm border-0 mb-4">
<div className="card-header d-flex flex-wrap align-items-center justify-content-between gap-2">
<span>FTP log (tail)</span>
<button type="button" className="btn btn-sm btn-outline-primary" disabled={logLoading} onClick={loadFtpLogs}>
{logLoading ? 'Loading…' : logContent ? 'Refresh' : 'Load log'}
</button>
</div>
<div className="card-body">
{logError ? <AdminAlert variant="danger">{logError}</AdminAlert> : null}
{logPath ? (
<p className="small text-muted mb-2">
Source: <code className="user-select-all">{logPath}</code>
</p>
) : null}
{logContent ? (
<pre
className="small bg-body-secondary border rounded p-3 mb-0 text-body"
style={{ maxHeight: '22rem', overflow: 'auto', whiteSpace: 'pre-wrap' }}
>
{logContent}
</pre>
) : !logLoading && !logError ? (
<p className="text-muted small mb-0">Click &quot;Load log&quot; to tail common Pure-FTPd paths on this server.</p>
) : null}
</div>
</div>
<Modal show={showCreate} onHide={() => setShowCreate(false)} centered>
<Modal.Header closeButton>
<Modal.Title>Create FTP Account</Modal.Title>
</Modal.Header>
<form onSubmit={handleCreate}>
<Modal.Body>
{creatingError ? <AdminAlert variant="danger">{creatingError}</AdminAlert> : null}
<div className="mb-3">
<label className="form-label">Username</label>
<input
name="name"
type="text"
placeholder="ftpuser"
className="form-control"
required
/>
</div>
<div className="mb-3">
<label className="form-label">Password</label>
<input name="password" type="password" className="form-control" required />
</div>
<div className="mb-3">
<label className="form-label">Path</label>
<input name="path" type="text" placeholder="/www/wwwroot" className="form-control" required />
</div>
<div className="mb-0">
<label className="form-label">Note (optional)</label>
<input name="ps" type="text" placeholder="My FTP" className="form-control" />
</div>
</Modal.Body>
<Modal.Footer>
<button type="button" className="btn btn-light" onClick={() => setShowCreate(false)}>
Cancel
</button>
<button type="submit" disabled={creating} className="btn btn-primary">
{creating ? 'Creating…' : 'Create'}
</button>
</Modal.Footer>
</form>
</Modal>
<div className="card shadow-sm border-0">
<div className="card-body p-0">
<AdminTable>
<thead className="table-light">
<tr>
<th>Name</th>
<th>Path</th>
<th>Note</th>
<th className="text-end">Actions</th>
</tr>
</thead>
<tbody>
{accounts.length === 0 ? (
<tr>
<td colSpan={4} className="p-0">
<EmptyState title="No FTP accounts" description='Click "Add FTP" to create one.' />
</td>
</tr>
) : (
accounts.map((a) => (
<tr key={a.id}>
<td className="align-middle">{a.name}</td>
<td className="align-middle">{a.path}</td>
<td className="align-middle text-muted">{a.ps || '—'}</td>
<td className="align-middle text-end">
<button
type="button"
onClick={() => setChangePwId(a.id)}
className="btn btn-sm btn-outline-warning me-1"
title="Change password"
>
<i className="ti ti-key" />
</button>
<button
type="button"
onClick={() => handleDelete(a.id, a.name)}
className="btn btn-sm btn-outline-danger"
title="Delete"
>
<i className="ti ti-trash" />
</button>
</td>
</tr>
))
)}
</tbody>
</AdminTable>
</div>
</div>
<Modal show={changePwId != null} onHide={() => setChangePwId(null)} centered>
<Modal.Header closeButton>
<Modal.Title>Change FTP Password</Modal.Title>
</Modal.Header>
{changePwId != null ? (
<form onSubmit={(e) => handleChangePassword(e, changePwId)}>
<Modal.Body>
{pwError ? <AdminAlert variant="danger">{pwError}</AdminAlert> : null}
<div className="mb-3">
<label className="form-label">New Password</label>
<input name="new_password" type="password" minLength={6} className="form-control" required />
</div>
<div className="mb-0">
<label className="form-label">Confirm Password</label>
<input name="confirm_password" type="password" minLength={6} className="form-control" required />
</div>
</Modal.Body>
<Modal.Footer>
<button type="button" className="btn btn-light" onClick={() => setChangePwId(null)}>
Cancel
</button>
<button type="submit" className="btn btn-primary">
Update
</button>
</Modal.Footer>
</form>
) : null}
</Modal>
</>
)
}