Files
MoShiro/web/admin/src/App.tsx
T

297 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { ElevationBar } from './ElevationBar'
import { PasskeyNotice } from './PasskeyNotice'
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 } = useAuth()
if (!ready) {
return (
<div className="login">
<Spinner />
</div>
)
}
if (!me) return <Login />
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" />
<ElevationBar />
<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' ? `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>
)
}