58 lines
2.9 KiB
TypeScript
58 lines
2.9 KiB
TypeScript
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>
|
||
)
|
||
}
|