287 lines
10 KiB
TypeScript
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 "Load log" 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>
|
|
</>
|
|
)
|
|
}
|