298 lines
12 KiB
TypeScript
298 lines
12 KiB
TypeScript
import { FlaskConical, KeyRound, ListChecks, LogOut, Plus, Radar, Server, Settings } from 'lucide-react'
|
||
import { useEffect, useRef, useState, type FormEvent } from 'react'
|
||
import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
|
||
import { ApiError } from '@shared/api/client'
|
||
import { activateSource, getBase, listSources } from '@shared/api/source'
|
||
import { conditionalSupported, passkeySupported } from '@shared/api/webauthn'
|
||
import { describeError, Field, Segmented, SourceButton, Spinner, ThemeToggle } from '@shared/ui'
|
||
import { useAuth } from './auth'
|
||
import { BackendUpdateNotice } from './BackendUpdateNotice'
|
||
import { PasskeyNotice } from './PasskeyNotice'
|
||
import { Setup } from './Setup'
|
||
import { AgentDetail } from './pages/AgentDetail'
|
||
import { Agents } from './pages/Agents'
|
||
import { Probes } from './pages/Probes'
|
||
import { System } from './pages/System'
|
||
import { Tasks } from './pages/Tasks'
|
||
import { Tests } from './pages/Tests'
|
||
|
||
/** 登录页:左边选后端(这个页面自己所在的,或保存过的,或添加一个),右边选登录方式:账号密码,或者 Passkey(只有页面自己所在的后端有)。 */
|
||
function Login() {
|
||
const { login, loginWithPasskey } = useAuth()
|
||
const active = getBase()
|
||
const saved = listSources()
|
||
const [adding, setAdding] = useState(false)
|
||
const [url, setUrl] = useState('')
|
||
const [username, setUsername] = useState('')
|
||
const [password, setPassword] = useState('')
|
||
const [error, setError] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
|
||
const choose = (base: string) => {
|
||
setAdding(false)
|
||
setError('')
|
||
setPassword('')
|
||
if (base !== active) {
|
||
activateSource(base)
|
||
location.reload()
|
||
}
|
||
}
|
||
|
||
const submit = async (e: FormEvent) => {
|
||
e.preventDefault()
|
||
setBusy(true)
|
||
setError('')
|
||
try {
|
||
await login(username, password, adding ? url : undefined)
|
||
} catch (err) {
|
||
setError(describeError(err))
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
// passkey 绑定在后端的域名上:只有页面自己所在的后端能用,别的后端(跨域)不显示
|
||
const canPasskey = passkeySupported() && !adding && !active
|
||
// 登录方式:记住上次用的;浏览器不支持或者是别的后端就只有账号密码
|
||
const [method, setMethod] = useState<'password' | 'passkey'>(() => {
|
||
try {
|
||
return localStorage.getItem('moshiro.login_method') === 'passkey' ? 'passkey' : 'password'
|
||
} catch {
|
||
return 'password'
|
||
}
|
||
})
|
||
const pickMethod = (next: 'password' | 'passkey') => {
|
||
setMethod(next)
|
||
setError('')
|
||
try {
|
||
localStorage.setItem('moshiro.login_method', next)
|
||
} catch {
|
||
// 记不住也只是下次回到默认
|
||
}
|
||
}
|
||
const usingPasskey = canPasskey && method === 'passkey'
|
||
// 自动填充只在「账号密码」页有用(它依附在用户名输入框上)
|
||
const autofillActive = canPasskey && method === 'password'
|
||
// 重新开始自动填充登录(点按钮的弹窗会顶掉它,取消之后要重新挂上)
|
||
const [round, setRound] = useState(0)
|
||
const retry = () => {
|
||
if (retryOnFocus.current) {
|
||
retryOnFocus.current = false
|
||
setRound((n) => n + 1)
|
||
}
|
||
}
|
||
const conditional = useRef<AbortController | null>(null) // 正在挂着的自动填充请求:点按钮之前要先取消,浏览器同一时间只允许一个 WebAuthn 请求
|
||
const retryOnFocus = useRef(false) // 服务器拒绝了自动填充选的 passkey:等用户再点输入框时才重新开始,不自己循环
|
||
const usePasskey = async () => {
|
||
setBusy(true)
|
||
setError('')
|
||
conditional.current?.abort()
|
||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||
try {
|
||
await loginWithPasskey()
|
||
} catch (err) {
|
||
// 用户取消(NotAllowedError)不当成错误
|
||
if (!(err instanceof DOMException && err.name === 'NotAllowedError')) setError(describeError(err))
|
||
} finally {
|
||
setBusy(false)
|
||
setRound((n) => n + 1)
|
||
}
|
||
}
|
||
|
||
// passkey 自动填充:点用户名输入框,浏览器会把保存的 passkey(Chrome 里是 Google 密码管理工具)列在下拉里,选一个就直接登录。
|
||
// 挑战 5 分钟过期,所以每 4 分钟换一个
|
||
useEffect(() => {
|
||
if (!autofillActive) return undefined
|
||
const abort = new AbortController()
|
||
conditional.current = abort
|
||
let timer: ReturnType<typeof setTimeout> | undefined
|
||
let expired = false // 只有「挑战到期」才重新开始;组件卸载、依赖变化时的取消不能再触发重来(否则会形成循环)
|
||
void conditionalSupported().then(async (ok) => {
|
||
if (abort.signal.aborted) return
|
||
if (!ok) return
|
||
timer = setTimeout(() => {
|
||
expired = true
|
||
abort.abort()
|
||
}, 4 * 60_000)
|
||
try {
|
||
await loginWithPasskey({ conditional: true, signal: abort.signal })
|
||
} catch (err) {
|
||
if (timer) clearTimeout(timer)
|
||
if (abort.signal.aborted) {
|
||
if (expired) setRound((n) => n + 1)
|
||
return
|
||
}
|
||
// 取消、被弹窗顶掉、浏览器不允许都不是错误;服务端拒绝(含限流)才显示
|
||
if (!(err instanceof DOMException)) {
|
||
retryOnFocus.current = true
|
||
setError(err instanceof ApiError && err.status === 401 ? '这个 Passkey 在服务器上已经不存在或已失效(可能被删除了),请换一个,或者用密码登录' : describeError(err))
|
||
}
|
||
}
|
||
})
|
||
return () => {
|
||
abort.abort()
|
||
if (timer) clearTimeout(timer)
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [autofillActive, round])
|
||
|
||
const rows = [{ base: '', label: location.host }, ...saved.map((x) => ({ base: x.base, label: x.base.replace(/^https?:\/\//i, '') }))]
|
||
const title = adding ? '添加后端' : active ? active.replace(/^https?:\/\//i, '') : location.origin
|
||
|
||
return (
|
||
<div className="login">
|
||
<form className="card login-split" onSubmit={submit}>
|
||
<aside className="login-side">
|
||
<div>
|
||
<h1>MoShiro</h1>
|
||
<p className="muted">管理端</p>
|
||
</div>
|
||
<div className="src-list" role="listbox" aria-label="后端">
|
||
{rows.map((row) => {
|
||
const on = !adding && row.base === active
|
||
return (
|
||
<button key={row.base || 'page'} type="button" role="option" aria-selected={on} className={`src-pick${on ? ' on' : ''}`} onClick={() => choose(row.base)}>
|
||
<span className="src-dot" />
|
||
<span className="src-text">
|
||
<b>{row.label}</b>
|
||
</span>
|
||
</button>
|
||
)
|
||
})}
|
||
<button
|
||
type="button"
|
||
role="option"
|
||
aria-selected={adding}
|
||
className={`src-pick${adding ? ' on' : ''}`}
|
||
onClick={() => {
|
||
setAdding(true)
|
||
setUrl('')
|
||
setPassword('')
|
||
setError('')
|
||
}}
|
||
>
|
||
<Plus size={14} />
|
||
<span className="src-text">
|
||
<b>添加后端</b>
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
<section className="login-main stack">
|
||
<h2>{title}</h2>
|
||
{canPasskey ? (
|
||
<Segmented
|
||
options={[
|
||
{ value: 'password' as const, label: '账号密码' },
|
||
{ value: 'passkey' as const, label: 'Passkey' },
|
||
]}
|
||
value={method}
|
||
onChange={pickMethod}
|
||
/>
|
||
) : null}
|
||
{usingPasskey ? (
|
||
<>
|
||
{error ? <div className="notice bad">{error}</div> : null}
|
||
<button className="btn primary" type="button" disabled={busy} onClick={() => void usePasskey()} autoFocus>
|
||
<KeyRound size={16} />
|
||
使用 Passkey 登录
|
||
</button>
|
||
<p className="faint" style={{ fontSize: 12 }}>
|
||
还没有 Passkey?先用「账号密码」登录,到「系统 → 用户 → 我的 Passkey」添加。
|
||
</p>
|
||
</>
|
||
) : (
|
||
<>
|
||
{adding ? (
|
||
<Field label="后端地址">
|
||
<input className="input" name="backend-url" type="text" inputMode="url" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="例如 https://你的域名" autoComplete="off" autoFocus required />
|
||
</Field>
|
||
) : null}
|
||
<Field label="用户名">
|
||
<input className="input" name="username" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete={autofillActive ? 'username webauthn' : 'username'} autoFocus={!adding} required onFocus={retry} onClick={retry} />
|
||
</Field>
|
||
<Field label="密码">
|
||
<input className="input" name="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" required />
|
||
</Field>
|
||
{error ? <div className="notice bad">{error}</div> : null}
|
||
<button className="btn primary" type="submit" disabled={busy}>
|
||
{adding ? '保存并登录' : '登录'}
|
||
</button>
|
||
</>
|
||
)}
|
||
</section>
|
||
</form>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const NAV = [
|
||
{ section: '运维', items: [{ to: '/agents', label: 'Agent', icon: Server }, { to: '/probes', label: 'Probe', icon: Radar }] },
|
||
{ section: '测试', items: [{ to: '/tasks', label: '任务', icon: ListChecks }, { to: '/tests', label: '按需测试', icon: FlaskConical }] },
|
||
{ section: '系统', items: [{ to: '/system', label: '系统', icon: Settings }] },
|
||
]
|
||
|
||
export function App() {
|
||
const { me, ready, logout, setupActive } = useAuth()
|
||
if (!ready) {
|
||
return (
|
||
<div className="login">
|
||
<Spinner />
|
||
</div>
|
||
)
|
||
}
|
||
if (setupActive) return <Setup />
|
||
if (!me) return <Login />
|
||
// 密码登录的会话没有任何能力:只给它添加 Passkey 的页面
|
||
|
||
return (
|
||
<div className="shell">
|
||
<aside className="side">
|
||
<a className="brand" href="/">
|
||
MoShiro
|
||
</a>
|
||
{NAV.map((group) => (
|
||
<div key={group.section} style={{ display: 'contents' }}>
|
||
<div className="section">{group.section}</div>
|
||
{group.items.map(({ to, label, icon: Icon }) => (
|
||
<NavLink key={to} to={to} className={({ isActive }) => `nav ${isActive ? 'active' : ''}`}>
|
||
<Icon size={20} />
|
||
{label}
|
||
</NavLink>
|
||
))}
|
||
</div>
|
||
))}
|
||
<div className="spacer" />
|
||
<SourceButton variant="status" />
|
||
<div className="me">
|
||
<span className="avatar">{(me.username ?? 'A').slice(0, 1).toUpperCase()}</span>
|
||
<span className="grow">
|
||
<div style={{ fontWeight: 600 }}>{me.username ?? 'API 令牌'}</div>
|
||
<div className="faint" style={{ fontSize: 12 }}>
|
||
{me.kind === 'token' && !me.auth_method ? `API 令牌 · ${me.capabilities.length} 项能力` : '管理员'}
|
||
</div>
|
||
</span>
|
||
<ThemeToggle />
|
||
<button type="button" className="btn icon" onClick={() => void logout()} aria-label="退出登录" title="退出登录">
|
||
<LogOut size={18} />
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
<div className="main">
|
||
<BackendUpdateNotice />
|
||
<PasskeyNotice />
|
||
<Routes>
|
||
<Route path="/" element={<Navigate to="/agents" replace />} />
|
||
<Route path="/agents" element={<Agents />} />
|
||
<Route path="/agents/:id" element={<AgentDetail />} />
|
||
<Route path="/probes" element={<Probes />} />
|
||
<Route path="/tasks" element={<Tasks />} />
|
||
<Route path="/tests" element={<Tests />} />
|
||
<Route path="/system" element={<System />} />
|
||
<Route path="*" element={<Navigate to="/agents" replace />} />
|
||
</Routes>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|