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(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 | 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 (

{title}

{canPasskey ? ( ) : null} {usingPasskey ? ( <> {error ?
{error}
: null}

还没有 Passkey?先用「账号密码」登录,到「系统 → 用户 → 我的 Passkey」添加。

) : ( <> {adding ? ( setUrl(e.target.value)} placeholder="例如 https://你的域名" autoComplete="off" autoFocus required /> ) : null} setUsername(e.target.value)} autoComplete={autofillActive ? 'username webauthn' : 'username'} autoFocus={!adding} required onFocus={retry} onClick={retry} /> setPassword(e.target.value)} autoComplete="current-password" required /> {error ?
{error}
: null} )}
) } 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 (
) } if (setupActive) return if (!me) return // 密码登录的会话没有任何能力:只给它添加 Passkey 的页面 return (
} /> } /> } /> } /> } /> } /> } /> } />
) }