feat: sign in with a passkey (WebAuthn, ES256, discoverable credentials) and manage your passkeys [CI]
This commit is contained in:
1 parent
ddb6608e32
commit
6385c1b9a3
31 files changed
+2296
-42
No files matched your search
@@ -0,0 +1,57 @@
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { passkeySupported, registerPasskey, type PasskeyPlace } from '@shared/api/webauthn'
|
||||
import { guessPlatform, useAction, useToast } from '@shared/ui'
|
||||
|
||||
/** 默认的 passkey 名称:按浏览器所在的系统给一个能认出来的名字。 */
|
||||
function defaultName(): string {
|
||||
const platform = guessPlatform()
|
||||
return platform === 'windows' ? 'Windows 设备' : platform === 'macos' ? 'Mac' : 'Linux 设备'
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加 passkey 的表单:名称 + 保存位置 + 按钮。「我的 Passkey」和登录后的提醒共用同一份。
|
||||
* 保存位置只是给浏览器的提示:`device` 优先用设备或浏览器账户自带的(Chrome 里是 Google 密码管理工具、Windows Hello),
|
||||
* `external` 是安全密钥或手机扫码。
|
||||
*/
|
||||
export function PasskeyAddForm({ onAdded, compact }: { onAdded: () => void; compact?: boolean }) {
|
||||
const { busy, run } = useAction()
|
||||
const toast = useToast()
|
||||
const [name, setName] = useState(defaultName)
|
||||
const [place, setPlace] = useState<PasskeyPlace>('device')
|
||||
const supported = passkeySupported()
|
||||
return (
|
||||
<div className={compact ? 'stack' : 'row wrap'} style={{ marginTop: compact ? 0 : 12 }}>
|
||||
<input className="input" style={{ maxWidth: compact ? undefined : 220 }} placeholder="名称,例如 MacBook" value={name} onChange={(e) => setName(e.target.value)} disabled={!supported} />
|
||||
<select className="input" style={{ maxWidth: compact ? undefined : 320 }} value={place} onChange={(e) => setPlace(e.target.value as PasskeyPlace)} disabled={!supported} title="保存位置只是给浏览器的提示,最终由浏览器和系统决定">
|
||||
<option value="device">保存在此设备或浏览器账户(Google 密码管理工具、Windows Hello)</option>
|
||||
<option value="external">安全密钥,或用手机扫码</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={busy || !supported || !name.trim()}
|
||||
title={supported ? '' : '这个浏览器不支持 Passkey(需要 HTTPS 或 localhost)'}
|
||||
onClick={async () => {
|
||||
let added: string | undefined
|
||||
const ok = await run(async () => {
|
||||
try {
|
||||
added = (await registerPasskey(name.trim(), place)).name
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'NotAllowedError') throw new Error('已取消')
|
||||
throw e
|
||||
}
|
||||
})
|
||||
if (ok) {
|
||||
// 成功提示停 10 秒:说清楚存了什么、下一步怎么用,点一下可以提前关掉
|
||||
toast(`「${added}」已经保存。下次登录时点用户名框选它,或者点「使用 Passkey 登录」;建议再添加一个备用的。`, false, { title: 'Passkey 添加成功', ms: 10_000 })
|
||||
onAdded()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Plus size={18} />
|
||||
添加 Passkey
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user