feat: sign in with a passkey (WebAuthn, ES256, discoverable credentials) and manage your passkeys [CI]

This commit is contained in:
justme committed 2026-10-07 21:57:19 +08:00
1 parent ddb6608e32
commit 6385c1b9a3
31 files changed
+2296 -42

No files matched your search

+146 -19
View File
@@ -1,10 +1,13 @@
import { FlaskConical, ListChecks, LogOut, Plus, Radar, Server, Settings } from 'lucide-react'
import { useState, type FormEvent } from 'react'
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 { describeError, Field, SourceButton, Spinner, ThemeToggle } from '@shared/ui'
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 { AgentDetail } from './pages/AgentDetail'
import { Agents } from './pages/Agents'
import { Probes } from './pages/Probes'
@@ -12,9 +15,9 @@ import { System } from './pages/System'
import { Tasks } from './pages/Tasks'
import { Tests } from './pages/Tests'
/** 登录页:左边选后端(这个页面自己所在的,或保存过的,或添加一个),右边都是用户名 + 密码。 */
/** 登录页:左边选后端(这个页面自己所在的,或保存过的,或添加一个),右边选登录方式:账号密码,或者 Passkey(只有页面自己所在的后端有)。 */
function Login() {
const { login } = useAuth()
const { login, loginWithPasskey } = useAuth()
const active = getBase()
const saved = listSources()
const [adding, setAdding] = useState(false)
@@ -47,6 +50,94 @@ function Login() {
}
}
// 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 请求
// 自动填充是否可用:unknown 还在检测,yes 已经挂上(点用户名框会列出已保存的 passkey),no 浏览器不支持
const [autofill, setAutofill] = useState<'unknown' | 'yes' | 'no'>('unknown')
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
setAutofill(ok ? 'yes' : 'no')
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
@@ -91,21 +182,56 @@ function Login() {
</aside>
<section className="login-main stack">
<h2>{title}</h2>
{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>
{canPasskey ? (
<Segmented
options={[
{ value: 'password' as const, label: '账号密码' },
{ value: 'passkey' as const, label: 'Passkey' },
]}
value={method}
onChange={pickMethod}
/>
) : null}
<Field label="用户名">
<input className="input" name="username" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" autoFocus={!adding} required />
</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>
{usingPasskey ? (
<>
<p className="muted">用指纹、人脸或安全密钥登录,不用输用户名和密码。点下面的按钮,浏览器会让你选一个这个站点的 Passkey。</p>
{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>
{canPasskey ? (
<p className="faint" style={{ margin: '-6px 0 0', fontSize: 12 }}>
{autofill === 'yes'
? '点一下用户名框:如果浏览器里已经保存了这个站点的 Passkey,会列在下拉里,选一个就直接登录。还没有的话,先用密码登录,到「系统 → 用户」添加。'
: autofill === 'no'
? '这个浏览器不支持从输入框里选 Passkey,请切到上面的「Passkey」。'
: ''}
</p>
) : null}
<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>
@@ -164,6 +290,7 @@ export function App() {
</aside>
<div className="main">
<BackendUpdateNotice />
<PasskeyNotice />
<Routes>
<Route path="/" element={<Navigate to="/agents" replace />} />
<Route path="/agents" element={<Agents />} />