增加SSL支持HTTPS/WSS

This commit is contained in:
MengMengCode
2026-06-09 13:06:26 +08:00
parent 835bb51c6e
commit c46f84c66e
7 changed files with 1089 additions and 105 deletions
+304 -82
View File
@@ -1,10 +1,13 @@
import { useCallback, useEffect, useState } from 'react'
import { Clock, Globe, LogIn, Monitor, UserCog } from 'lucide-react'
import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react'
import { Clock, Globe, Lock, LogIn, Monitor, RefreshCw, ShieldCheck, Upload, UserCog } from 'lucide-react'
import {
changePassword,
changeUsername,
getLoginLogs,
getSSLSettings,
LoginLog,
SSLSettings,
updateSSLSettings,
} from '../services/api'
import { useDialog } from '../components/Dialog'
import { useAuth } from '../contexts/AuthContext'
@@ -21,6 +24,16 @@ export default function Settings() {
const [newPwd, setNewPwd] = useState('')
const [newUsername, setNewUsername] = useState('')
const [ssl, setSSL] = useState<SSLSettings | null>(null)
const [sslEnabled, setSSLEnabled] = useState(false)
const [sslMode, setSSLMode] = useState<SSLSettings['mode']>('disabled')
const [sslTarget, setSSLTarget] = useState('')
const [sslEmail, setSSLEmail] = useState('')
const [certPEM, setCertPEM] = useState('')
const [keyPEM, setKeyPEM] = useState('')
const [applyNow, setApplyNow] = useState(true)
const [savingSSL, setSavingSSL] = useState(false)
const fetchLogs = useCallback(async () => {
try {
const res = await getLoginLogs()
@@ -32,11 +45,61 @@ export default function Settings() {
}
}, [])
const fetchSSL = useCallback(async () => {
try {
const res = await getSSLSettings()
const data = res.data.data
if (!data) return
setSSL(data)
setSSLEnabled(data.enabled)
setSSLMode(data.mode || 'disabled')
setSSLTarget(data.target || data.detected_host || '')
setSSLEmail(data.email || '')
} catch (err) {
console.error(err)
}
}, [])
useEffect(() => {
fetchLogs()
fetchSSL()
const timer = setInterval(fetchLogs, 15000)
return () => clearInterval(timer)
}, [fetchLogs])
}, [fetchLogs, fetchSSL])
const handleSSLModeChange = (mode: SSLSettings['mode']) => {
setSSLMode(mode)
const saved = ssl?.mode_certificates?.[mode]
setSSLTarget(saved?.target || ssl?.detected_host || sslTarget)
setSSLEmail(saved?.email || '')
}
const handleSaveSSL = async () => {
setSavingSSL(true)
try {
const enabled = sslEnabled && sslMode !== 'disabled'
const res = await updateSSLSettings({
enabled,
mode: enabled ? sslMode : 'disabled',
target: sslTarget,
email: sslEmail,
cert_pem: certPEM,
key_pem: keyPEM,
apply_now: applyNow,
})
if (res.data.data) {
setSSL(res.data.data)
setCertPEM('')
setKeyPEM('')
}
dialog.alert('完成', applyNow ? 'SSL 设置已保存,服务正在重启。稍后请用新的协议重新打开面板。' : 'SSL 设置已保存,重启 clicd 服务后生效。')
} catch (err: unknown) {
const e = err as { response?: { data?: { message?: string } } }
dialog.alert('失败', e.response?.data?.message || 'SSL 设置保存失败')
} finally {
setSavingSSL(false)
}
}
const handleSaveAccount = async () => {
if (!oldPwd) {
@@ -92,93 +155,252 @@ export default function Settings() {
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-black"></h1>
<p className="mt-1 text-sm text-gray-500"></p>
<p className="mt-1 text-sm text-gray-500"></p>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-5">
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
<UserCog className="h-4 w-4" />
</h2>
<div className="space-y-4">
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="text" value={username || ''} disabled className="w-full rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-400" />
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1.15fr)_minmax(360px,0.85fr)]">
<SSLCard
ssl={ssl}
sslEnabled={sslEnabled}
sslMode={sslMode}
sslTarget={sslTarget}
sslEmail={sslEmail}
certPEM={certPEM}
keyPEM={keyPEM}
applyNow={applyNow}
savingSSL={savingSSL}
onRefresh={fetchSSL}
onEnabledChange={setSSLEnabled}
onModeChange={handleSSLModeChange}
onTargetChange={setSSLTarget}
onEmailChange={setSSLEmail}
onCertChange={setCertPEM}
onKeyChange={setKeyPEM}
onApplyNowChange={setApplyNow}
onSave={handleSaveSSL}
/>
<div className="rounded-lg border border-gray-200 bg-white p-5">
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
<UserCog className="h-4 w-4" />
</h2>
<div className="space-y-4">
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="text" value={username || ''} disabled className="w-full rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-400" />
</div>
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 3 位" />
</div>
<div className="border-t border-gray-100 pt-3">
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 6 位" />
</div>
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="输入当前密码以确认修改" />
</div>
<button onClick={handleSaveAccount} className="w-full rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800"></button>
</div>
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 3 位" />
</div>
<div className="border-t border-gray-100 pt-3">
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 6 位" />
</div>
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="输入当前密码以确认修改" />
</div>
<button onClick={handleSaveAccount} className="w-full rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800"></button>
</div>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-5">
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
<LogIn className="h-4 w-4" />
<LoginLogCard logs={logs} logPage={logPage} pageSize={pageSize} totalPages={totalPages} setLogPage={setLogPage} />
</div>
)
}
interface SSLCardProps {
ssl: SSLSettings | null
sslEnabled: boolean
sslMode: SSLSettings['mode']
sslTarget: string
sslEmail: string
certPEM: string
keyPEM: string
applyNow: boolean
savingSSL: boolean
onRefresh: () => void
onEnabledChange: (enabled: boolean) => void
onModeChange: (mode: SSLSettings['mode']) => void
onTargetChange: (target: string) => void
onEmailChange: (email: string) => void
onCertChange: (cert: string) => void
onKeyChange: (key: string) => void
onApplyNowChange: (apply: boolean) => void
onSave: () => void
}
function SSLCard(props: SSLCardProps) {
const selectedSSL = props.ssl?.mode_certificates?.[props.sslMode]
const modeOptions: Array<{ value: SSLSettings['mode']; label: string }> = [
{ value: 'letsencrypt', label: 'Lets Encrypt' },
{ value: 'self_signed', label: '自签证书' },
{ value: 'uploaded', label: '上传证书' },
]
return (
<div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 text-sm font-semibold text-black">
<ShieldCheck className="h-4 w-4" />SSL
</h2>
{logs.length === 0 ? (
<p className="text-sm text-gray-400"></p>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-100 text-gray-400">
<th className="w-40 py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Clock className="h-3 w-3" /></span></th>
<th className="py-2 text-left font-medium"></th>
<th className="py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Globe className="h-3 w-3" />IP</span></th>
<th className="py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Monitor className="h-3 w-3" /></span></th>
<th className="py-2 text-left font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{logs.slice((logPage - 1) * pageSize, logPage * pageSize).map((log, index) => (
<tr key={`${log.time}-${index}`}>
<td className="whitespace-nowrap py-1.5 font-mono text-gray-500">{log.time}</td>
<td className="py-1.5 text-gray-700">{log.username}</td>
<td className="py-1.5 font-mono text-gray-500">{log.ip}</td>
<td className="max-w-[180px] truncate py-1.5 text-gray-500" title={log.user_agent}>{formatUA(log.user_agent)}</td>
<td className="py-1.5">
<span className={`rounded px-1.5 py-0.5 text-xs ${log.success ? 'bg-gray-100 text-gray-700' : 'bg-red-50 text-red-600'}`}>
{log.success ? '成功' : '失败'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{logs.length > pageSize && (
<div className="mt-3 flex items-center justify-between border-t border-gray-100 pt-3">
<span className="text-xs text-gray-400"> {logs.length} {logPage}/{totalPages} </span>
<div className="flex items-center gap-1">
<button onClick={() => setLogPage(1)} disabled={logPage === 1} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30"></button>
<button onClick={() => setLogPage(p => Math.max(1, p - 1))} disabled={logPage === 1} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30"></button>
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let start = Math.max(1, logPage - 2)
if (start + 4 > totalPages) start = Math.max(1, totalPages - 4)
const page = start + i
if (page > totalPages) return null
return (
<button key={page} onClick={() => setLogPage(page)} className={`h-7 w-7 rounded text-xs ${page === logPage ? 'bg-black text-white' : 'border border-gray-200 hover:bg-gray-50'}`}>{page}</button>
)
})}
<button onClick={() => setLogPage(p => Math.min(totalPages, p + 1))} disabled={logPage >= totalPages} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30"></button>
<button onClick={() => setLogPage(totalPages)} disabled={logPage >= totalPages} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30"></button>
</div>
</div>
)}
</>
)}
<button onClick={props.onRefresh} className="rounded-md border border-gray-200 p-1.5 text-gray-500 hover:bg-gray-50" title="刷新">
<RefreshCw className="h-4 w-4" />
</button>
</div>
<div className="space-y-4">
<label className="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" checked={props.sslEnabled} onChange={(e) => props.onEnabledChange(e.target.checked)} className="h-4 w-4 rounded border-gray-300" />
HTTPS / WSS
</label>
<div className="grid gap-2 sm:grid-cols-3">
{modeOptions.map((option) => (
<button
key={option.value}
onClick={() => props.onModeChange(option.value)}
className={`rounded-md border px-3 py-2 text-sm ${props.sslMode === option.value ? 'border-black bg-black text-white' : 'border-gray-200 text-gray-700 hover:bg-gray-50'}`}
>
{option.label}
</button>
))}
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs text-gray-500">IP / </label>
<input
type="text"
value={props.sslTarget}
onChange={(e) => props.onTargetChange(e.target.value)}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black"
placeholder={props.ssl?.detected_host || '服务器公网 IP 或域名'}
/>
</div>
{props.sslMode === 'letsencrypt' && (
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="email" value={props.sslEmail} onChange={(e) => props.onEmailChange(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="admin@example.com" />
</div>
)}
</div>
{props.sslMode === 'letsencrypt' && (
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
IP Certbot 5.4+ 80 Lets Encrypt 访IP certbot
</div>
)}
{props.sslMode === 'self_signed' && (
<div className="rounded-md border border-gray-100 bg-gray-50 p-3 text-xs text-gray-600">
VNC
</div>
)}
{props.sslMode === 'uploaded' && (
<div className="grid gap-3 lg:grid-cols-2">
<div>
<label className="mb-1 block text-xs text-gray-500"> PEM / fullchain.pem</label>
<textarea value={props.certPEM} onChange={(e) => props.onCertChange(e.target.value)} rows={7} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-black" placeholder="-----BEGIN CERTIFICATE-----" />
</div>
<div>
<label className="mb-1 block text-xs text-gray-500"> PEM / privkey.pem</label>
<textarea value={props.keyPEM} onChange={(e) => props.onKeyChange(e.target.value)} rows={7} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-black" placeholder="-----BEGIN PRIVATE KEY-----" />
</div>
</div>
)}
{selectedSSL?.certificate ? (
<div className="rounded-md border border-gray-100 bg-gray-50 p-3 text-xs text-gray-600">
<div className="flex items-center gap-2 text-gray-800">
<Lock className="h-3.5 w-3.5" />
{selectedSSL.certificate.valid ? '有效' : '已过期或未生效'}
</div>
<div className="mt-1 font-mono">{selectedSSL.certificate.not_after}</div>
<div className="mt-1 truncate font-mono" title={selectedSSL.cert_path}>{selectedSSL.cert_path || '-'}</div>
{selectedSSL.last_error && <div className="mt-1 text-red-600">{selectedSSL.last_error}</div>}
</div>
) : (
<div className="rounded-md border border-gray-100 bg-gray-50 p-3 text-xs text-gray-600">
{props.sslMode === 'uploaded' ? '上传来源还没有保存证书,请粘贴证书和私钥后保存。' : '当前来源还没有保存证书,保存 SSL 设置时会自动生成或申请。'}
</div>
)}
<label className="flex items-center gap-2 text-xs text-gray-500">
<input type="checkbox" checked={props.applyNow} onChange={(e) => props.onApplyNowChange(e.target.checked)} className="h-4 w-4 rounded border-gray-300" />
</label>
<button onClick={props.onSave} disabled={props.savingSSL} className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
<Upload className="h-4 w-4" />
{props.savingSSL ? '保存中...' : '保存 SSL 设置'}
</button>
</div>
</div>
)
}
interface LoginLogCardProps {
logs: LoginLog[]
logPage: number
pageSize: number
totalPages: number
setLogPage: Dispatch<SetStateAction<number>>
}
function LoginLogCard({ logs, logPage, pageSize, totalPages, setLogPage }: LoginLogCardProps) {
return (
<div className="rounded-lg border border-gray-200 bg-white p-5">
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
<LogIn className="h-4 w-4" />
</h2>
{logs.length === 0 ? (
<p className="text-sm text-gray-400"></p>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-100 text-gray-400">
<th className="w-40 py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Clock className="h-3 w-3" /></span></th>
<th className="py-2 text-left font-medium"></th>
<th className="py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Globe className="h-3 w-3" />IP</span></th>
<th className="py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Monitor className="h-3 w-3" /></span></th>
<th className="py-2 text-left font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{logs.slice((logPage - 1) * pageSize, logPage * pageSize).map((log, index) => (
<tr key={`${log.time}-${index}`}>
<td className="whitespace-nowrap py-1.5 font-mono text-gray-500">{log.time}</td>
<td className="py-1.5 text-gray-700">{log.username}</td>
<td className="py-1.5 font-mono text-gray-500">{log.ip}</td>
<td className="max-w-[180px] truncate py-1.5 text-gray-500" title={log.user_agent}>{formatUA(log.user_agent)}</td>
<td className="py-1.5">
<span className={`rounded px-1.5 py-0.5 text-xs ${log.success ? 'bg-gray-100 text-gray-700' : 'bg-red-50 text-red-600'}`}>
{log.success ? '成功' : '失败'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{logs.length > pageSize && (
<div className="mt-3 flex items-center justify-between border-t border-gray-100 pt-3">
<span className="text-xs text-gray-400"> {logs.length} {logPage}/{totalPages} </span>
<div className="flex items-center gap-1">
<button onClick={() => setLogPage(1)} disabled={logPage === 1} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30"></button>
<button onClick={() => setLogPage(p => Math.max(1, p - 1))} disabled={logPage === 1} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30"></button>
<button onClick={() => setLogPage(p => Math.min(totalPages, p + 1))} disabled={logPage >= totalPages} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30"></button>
<button onClick={() => setLogPage(totalPages)} disabled={logPage >= totalPages} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30"></button>
</div>
</div>
)}
</>
)}
</div>
)
}
+41
View File
@@ -317,6 +317,47 @@ export interface AuditLog {
export const getLoginLogs = () =>
api.get<APIResponse<LoginLog[]>>('/login-logs')
export interface SSLCertificateInfo {
subject: string
issuer: string
dns_names: string[]
ip_names: string[]
not_before: string
not_after: string
valid: boolean
}
export interface SSLSettings {
enabled: boolean
mode: 'disabled' | 'letsencrypt' | 'self_signed' | 'uploaded'
target: string
email?: string
cert_path?: string
key_path?: string
last_issued_at?: string
last_error?: string
detected_host?: string
certificate?: SSLCertificateInfo
mode_certificates?: Record<string, SSLSettings>
needs_restart?: boolean
}
export interface UpdateSSLSettingsRequest {
enabled: boolean
mode: 'disabled' | 'letsencrypt' | 'self_signed' | 'uploaded'
target?: string
email?: string
cert_pem?: string
key_pem?: string
apply_now?: boolean
}
export const getSSLSettings = () =>
api.get<APIResponse<SSLSettings>>('/ssl')
export const updateSSLSettings = (data: UpdateSSLSettingsRequest) =>
api.put<APIResponse<SSLSettings>>('/ssl', data)
// Containers
export const getContainers = () =>
api.get<APIResponse<Container[]>>('/containers')