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

+58 -1
View File
@@ -1,13 +1,14 @@
import { Download, KeyRound, Plus, Trash2 } from 'lucide-react'
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { api } from '@shared/api/client'
import type { AgentDto, ApiTokenDto, BuildInfoDto, CapabilityCatalogDto, MeDto, NodeUpdateDto, ReleaseDto, CreatedApiTokenDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types'
import type { AgentDto, ApiTokenDto, BuildInfoDto, CapabilityCatalogDto, MeDto, PasskeyDto, NodeUpdateDto, ReleaseDto, CreatedApiTokenDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types'
import { fmtBuiltAt, frontendBuild } from '@shared/buildInfo'
import { fmtNum, fmtTime } from '@shared/format'
import { translateSetting } from '@shared/i18n'
import { useQuery } from '@shared/hooks'
import { Chip, Confirm, CopyButton, Dialog, Empty, ErrorNotice, Field, Segmented, Spinner, useAction } from '@shared/ui'
import { CapabilityPicker, capabilityName } from './CapabilityPicker'
import { PasskeyAddForm } from './PasskeyAdd'
type Tab = 'settings' | 'versions' | 'tokens' | 'users' | 'events' | 'raw' | 'analysis'
@@ -495,6 +496,59 @@ function Tokens() {
)
}
function Passkeys() {
const list = useQuery(() => api.get<Page<PasskeyDto>>('/api/admin/auth/passkeys'), [])
const { run } = useAction()
const [removing, setRemoving] = useState<PasskeyDto>()
const items = list.data?.items ?? []
return (
<div className="card" style={{ marginBottom: 16 }}>
<div className="row" style={{ marginBottom: 12 }}>
<b>我的 Passkey</b>
<span className="muted">用指纹、人脸或安全密钥登录,不用输密码。建议至少注册两个(比如手机和安全密钥)。</span>
</div>
<ErrorNotice error={list.error} />
{items.length === 0 ? (
<Empty>还没有 Passkey</Empty>
) : (
<table className="table">
<thead>
<tr>
<th>名称</th>
<th>创建</th>
<th>最后使用</th>
<th />
</tr>
</thead>
<tbody>
{items.map((p) => (
<tr key={p.id}>
<td style={{ fontWeight: 600 }}>
{p.name} {p.transports.includes('internal') ? <Chip>本机</Chip> : p.transports.length > 0 ? <Chip>{p.transports.join('、')}</Chip> : null}
</td>
<td className="muted">{fmtTime(p.created_at)}</td>
<td className="muted">{p.last_used_at ? fmtTime(p.last_used_at) : '从未'}</td>
<td className="r">
<button type="button" className="btn small danger" onClick={() => setRemoving(p)}>
<Trash2 size={14} />
删除
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
<PasskeyAddForm onAdded={list.reload} />
{removing ? (
<Confirm title="删除 Passkey" danger confirmText="删除" onClose={() => setRemoving(undefined)} onConfirm={async () => { await run(() => api.del(`/api/admin/auth/passkeys/${removing.id}`), '已删除'); list.reload() }}>
「{removing.name}」删除之后不能再用来登录。如果这是你唯一的 Passkey,仍然可以用密码登录。
</Confirm>
) : null}
</div>
)
}
function Users() {
const users = useQuery(() => api.get<Page<UserDto>>('/api/admin/users'), [])
const { run } = useAction()
@@ -502,6 +556,8 @@ function Users() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
return (
<>
<Passkeys />
<div className="card">
<div className="row" style={{ marginBottom: 12 }}>
<span className="muted">能登录管理端的账号。</span>
@@ -576,6 +632,7 @@ function Users() {
</Dialog>
) : null}
</div>
</>
)
}