feat!: sensitive actions need a fresh passkey touch (elevation windows), plus login page hint cleanup [CI]
This commit is contained in:
1 parent
1379b8c7cb
commit
ff45ad086e
27 files changed
+1305
-58
No files matched your search
+2
-13
@@ -7,6 +7,7 @@ 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'
|
||||
@@ -81,8 +82,6 @@ function Login() {
|
||||
}
|
||||
}
|
||||
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)
|
||||
@@ -110,7 +109,6 @@ function Login() {
|
||||
let expired = false // 只有「挑战到期」才重新开始;组件卸载、依赖变化时的取消不能再触发重来(否则会形成循环)
|
||||
void conditionalSupported().then(async (ok) => {
|
||||
if (abort.signal.aborted) return
|
||||
setAutofill(ok ? 'yes' : 'no')
|
||||
if (!ok) return
|
||||
timer = setTimeout(() => {
|
||||
expired = true
|
||||
@@ -194,7 +192,6 @@ function Login() {
|
||||
) : null}
|
||||
{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} />
|
||||
@@ -214,15 +211,6 @@ function Login() {
|
||||
<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>
|
||||
@@ -273,6 +261,7 @@ export function App() {
|
||||
</div>
|
||||
))}
|
||||
<div className="spacer" />
|
||||
<ElevationBar />
|
||||
<SourceButton variant="status" />
|
||||
<div className="me">
|
||||
<span className="avatar">{(me.username ?? 'A').slice(0, 1).toUpperCase()}</span>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { ShieldCheck } from 'lucide-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api, ApiError, setElevationHandler } from '@shared/api/client'
|
||||
import type { ElevationDto, Page } from '@shared/api/types'
|
||||
import { ELEVATED_EVENT, elevate, passkeySupported, type ElevationClass } from '@shared/api/webauthn'
|
||||
import { useAuth } from './auth'
|
||||
|
||||
const NAMES: Record<string, string> = { enroll: '添加节点', revoke: '吊销', credential: '改凭证' }
|
||||
|
||||
function remaining(until: string, now: number): string {
|
||||
const seconds = Math.max(0, Math.round((Date.parse(until) - now) / 1000))
|
||||
return seconds >= 60 ? `${Math.ceil(seconds / 60)} 分钟` : `${seconds} 秒`
|
||||
}
|
||||
|
||||
/**
|
||||
* 敏感操作要先触碰 passkey:这里注册全局的处理函数(API 返回 `elevation_required` 时自动弹出触碰,成功后重试原请求),
|
||||
* 并在侧栏显示当前会话开着的提权窗口和剩余时间,可以提前结束。
|
||||
*/
|
||||
export function ElevationBar() {
|
||||
const { me } = useAuth()
|
||||
const [windows, setWindows] = useState<ElevationDto[]>([])
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const signedIn = !!me?.user_id
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (!signedIn) return
|
||||
api
|
||||
.get<Page<ElevationDto>>('/api/admin/auth/elevation')
|
||||
.then((page) => setWindows(page.items))
|
||||
.catch(() => undefined)
|
||||
}, [signedIn])
|
||||
|
||||
useEffect(() => {
|
||||
if (!signedIn || !passkeySupported()) return undefined
|
||||
setElevationHandler(async (kind) => {
|
||||
try {
|
||||
return await elevate(kind as ElevationClass)
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 400) throw new Error('先添加一个 Passkey 才能做这个操作(系统 → 用户 → 我的 Passkey)')
|
||||
throw e
|
||||
}
|
||||
})
|
||||
refresh()
|
||||
window.addEventListener(ELEVATED_EVENT, refresh)
|
||||
const tick = setInterval(() => setNow(Date.now()), 1000)
|
||||
return () => {
|
||||
setElevationHandler(null)
|
||||
window.removeEventListener(ELEVATED_EVENT, refresh)
|
||||
clearInterval(tick)
|
||||
}
|
||||
}, [signedIn, refresh])
|
||||
|
||||
const open = windows.filter((w) => Date.parse(w.expires_at) > now)
|
||||
if (open.length === 0) return null
|
||||
return (
|
||||
<div className="notice" style={{ margin: '0 0 8px', padding: '8px 10px' }}>
|
||||
<div className="row" style={{ gap: 6 }}>
|
||||
<ShieldCheck size={16} />
|
||||
<b>已用 Passkey 确认</b>
|
||||
<button
|
||||
type="button"
|
||||
className="btn small text right"
|
||||
onClick={() => {
|
||||
api.del('/api/admin/auth/elevation').then(() => setWindows([])).catch(() => undefined)
|
||||
}}
|
||||
>
|
||||
结束
|
||||
</button>
|
||||
</div>
|
||||
{open.map((w) => (
|
||||
<div key={w.class} className="faint" style={{ fontSize: 12 }}>
|
||||
{NAMES[w.class] ?? w.class}:还剩 {remaining(w.expires_at, now)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user