安装
pnpm dlx shadcn@latest add https://yiui.dev/r/auth-05.json--dry-run 或 --diff 看清差异再装。app/login-verify/page.tsx,安装后就是一个可以访问的路由。生产项目里请按需删除,或改成自己的路径。用法
<Auth05
account={{ email: "linwei@example.com", phone: "13800138000" }}
helpHref="/support"
onPasskey={async () => { await verifyPasskey() }}
onSendCode={async () => { await sendSms() }}
onVerify={async ({ method, code }) => { await verifyCode(method, code) }}
onSuccess={({ method, rememberDevice }) => location.assign("/")}
onSwitchAccount={() => location.assign("/login")}
/>这是登录的第二步:账号已经通过第一步认证,这里要求用户再完成一次验证。顶部的账号摘要显示脱敏邮箱(lin***@example.com)和「不是你?切换账号」;下面是标题、当前验证方式的面板,以及「其他验证方式」列表。
桌面宽度下,「其他验证方式」放在主面板右侧的一栏,用虚线与面板分开。这样主面板的位置和高度不随切换变化,其余方式始终一眼可见;窄屏变成单列,列表排在面板下方。列表里不包含当前方式,点一项就切换过去。
验证方式
methods 决定哪些方式可用,顺序就是列表顺序,默认四种全开;defaultMethod 指定默认方式(不在 methods 里时退回第一项)。
| 方式 | 说明 | 回调 |
|---|---|---|
passkey 通行密钥 | 点主按钮,等待系统弹窗,完成后成功;也可能取消或出错 | onPasskey() |
totp 身份验证器 App | 6 位验证码,输满自动提交,错误态与 手机号验证码登录 一致 | onVerify({ method: "totp", code }) |
sms 短信验证码 | 显示脱敏手机号,点「发送验证码」后出现输入框和重发倒计时 | onSendCode(),再 onVerify({ method: "sms", code }) |
recovery 恢复码 | 单个输入框,默认自动转大写并在第 4 位后插入短横线,格式 XXXX-XXXX(可用 recoveryCode 改格式或关闭格式化),旁边提示每个恢复码只能用一次 | onVerify({ method: "recovery", code }) |
通行密钥面板的状态:
| 状态 | 表现 |
|---|---|
| 初始 | 主按钮「使用通行密钥验证」 |
| 等待中 | 按钮禁用并带加载图标,提示「请在浏览器或系统弹窗中完成验证」;这期间不能切换方式 |
| 已取消 | 回调抛出 name 为 NotAllowedError 的错误,显示「已取消,可以重试」,按钮恢复可点 |
| 出错 | 其他错误,显示 Error.message |
| 不支持 | 检测不到 PublicKeyCredential 时,显示「当前浏览器不支持通行密钥」,主按钮禁用,并给出「改用 xxx」的按钮 |
验证成功后整块内容换成成功状态,并调用 onSuccess({ method, rememberDevice })。
账号摘要
account 的 email、phone、name、username 至少要有一个。类型用联合类型表达,漏传时编译期就会报错;运行时仍有兜底(摘要为空、头像显示 ?),JS 项目也不会崩溃。
显示规则:
| 条件 | 摘要显示 |
|---|---|
有 name 和 username | 林清禾 @qinghe,username 用小字补充,都不脱敏 |
只有 name 或 username | 原样显示,不脱敏 |
都没有,有 email | 脱敏邮箱,如 lin***@example.com |
都没有,只有 phone | 脱敏手机号,如 138 **** 8000 |
头像首字取实际显示的那个名字(有 name 用 name,否则 username、email、phone)。读屏读到的是「当前账号:」加上摘要内容,不写死「邮箱」。phone 另外用于短信面板,所以只用 name 登录的账号如果还要短信验证,也要传 phone。
<Auth05 account={{ name: "林清禾", username: "qinghe" }} />
<Auth05 account={{ username: "qinghe" }} />
<Auth05 account={{ email: "linwei@example.com", phone: "13800138000" }} />备用码格式
recoveryCode 配置备用码输入框,不传时是 XXXX-XXXX,和之前的行为一致。
| 字段 | 说明 |
|---|---|
format | 掩码字符串:X(大写)代表一个字母或数字,其他字符原样插入,只在输入写到那一位时才插入,所以退格不会卡在分隔符上。默认 "XXXX-XXXX"。传 false 不做格式化:输入框里是什么就是什么,提交时只去掉首尾空白 |
uppercase | 输入时就把输入框里的值转成大写(不是只靠 CSS 的 text-transform),提交的值也是大写。有掩码时默认 true,format 为 false 时默认 false(原样输入),显式传 true 才转 |
maxLength | 最大字符数,只在 format 为 false 时生效;有掩码时长度由掩码决定。按输入框里的原始字符计数,连字符和空格都算;提交前会去掉首尾空白,长度判断用去空白之后的值 |
placeholder | 占位文字,默认由 format 生成(false 时为空) |
onVerify 收到的 code:
- 有掩码:输入框里的值,如
ABCD-1234(含掩码里的分隔符)。 format: false:去掉首尾空白、按uppercase处理过的原值。不会自动去掉连字符:用户输入ABCD-1234就收到ABCD-1234,输入ABCD1234就收到ABCD1234,后端需要自己兼容带或不带连字符两种写法。
超长不会被悄悄截断
format: false 时,组件不给输入框设原生 maxLength,也不在代码里截断。粘贴超过 maxLength 的内容会原样保留,输入框下方出现错误(labels.recoveryTooLong,默认「备用码最多 9 个字符,当前 12 个,请检查是否多粘贴了内容」,支持 {max}、{length}),输入框标记 aria-invalid 并通过 aria-describedby 关联这条 role="alert" 的提示,读屏会播报。超长期间提交按钮禁用,回车也提交不了,焦点留在输入框;删回长度以内,错误立即消失。用禁用按钮而不是“点了再报错”,是因为错误文字已经说明了原因,和“未填完不能提交”的现有表现一致。
有掩码时多出的字符本来就无处可放,所以按掩码只取需要的位数,多余的部分不进入;这时会在输入框下方给一条不带警告语气的提示(labels.recoveryTrimmed,默认「已按格式保留前 8 位」,{count} 是掩码能放的位数),放在 role="status" 的播报区里,读屏会播报,下一次输入时重新计算并清除。
// 掩码:5 位一组,输入 abcde12345 显示为 ABCDE-12345
<Auth05 account={account} recoveryCode={{ format: "XXXXX-XXXXX" }} />
// 不格式化:后端的备用码格式不固定,输入什么就提交什么
<Auth05
account={account}
recoveryCode={{ format: false, maxLength: 32, placeholder: "粘贴备用码" }}
/>后端生成的 8 位码
备用码由后端生成,8 位 A–Z/0–9,形如 XXXX-XXXX,用户也可能输入不带连字符的写法。不想让组件替你补或去连字符,就关掉格式化,只做大写和长度限制:
<Auth05Panel
account={{ username }}
methods={["totp", "recovery"]}
showRememberDevice={false}
recoveryCode={{
format: false,
uppercase: true, // 输入框里的值就是大写
maxLength: 9, // "XXXX-XXXX" 含连字符共 9 个字符
placeholder: "XXXX-XXXX",
}}
onVerify={async ({ method, code }) => {
// code 是 "ABCD-1234" 或 "ABCD1234",组件不会替你去掉连字符
const normalized = method === "recovery" ? code.replaceAll("-", "") : code
await verifyCode(method, normalized)
}}
/>输入 abcd-1234 时输入框里立刻是 ABCD-1234,输入 abcd1234 是 ABCD1234,粘贴 12 个字符会看到超长错误而不是被截成 9 个。
有掩码时,输入未填满不能提交,错误文案是 labels.recoveryError(其中的 {format} 会替换成掩码);format 为 false 时只要求非空,空提交显示 labels.recoveryEmpty。「每个恢复码只能用一次」的提示不变。
隐藏「记住此设备」
后端不支持记住设备时传 showRememberDevice={false}:开关不渲染,onSuccess 里的 rememberDevice 固定为 false,即使你传了受控的 rememberDevice。默认 true,行为和之前一致。
嵌进已有的登录布局
Auth05 是整页:线条网格外壳、整页的上下留白、两栏排版。如果你已经有登录页的外壳,比如 分屏登录页 那种左边品牌面板、右边表单的分屏布局,第二步要在同一个布局里换掉右侧的表单,用 Auth05Panel。
Auth05Panel 只有验证内容:标题和说明、账号摘要、当前方式的面板、其他验证方式、记住设备和帮助链接。没有页面外壳,宽度跟随父容器,也不设最小高度;「其他验证方式」一律在当前方式下方单列,因为嵌入的容器通常很窄。props 和 Auth05 完全相同,也支持 brand(不传就不显示品牌)。
import { Auth05Panel } from "@/components/auth-05/auth-05-panel"
export function VerifyStep({ username }: { username: string }) {
return (
<div className="grid min-h-svh md:grid-cols-2">
{/* 左侧:和第一步同一块品牌面板 */}
<BrandPanel className="hidden md:flex" />
{/* 右侧:把第一步的登录表单换成验证面板 */}
<div className="flex items-center justify-center p-6 sm:p-10">
<div className="w-full max-w-sm">
<Auth05Panel
account={{ username }}
methods={["totp", "recovery"]}
showRememberDevice={false}
recoveryCode={{ format: false }}
onVerify={async ({ method, code }) => { await verifyCode(method, code) }}
onSuccess={() => location.assign("/")}
onSwitchAccount={() => location.assign("/login")}
/>
</div>
</div>
</div>
)
}两者共用同一套状态与零件,只是排版不同,所以回调、受控属性、labels 的用法完全一致。
只用面板
只用 Auth05Panel、已经有自己的登录页外壳时,装 auth-05-panel 就够了,不必装整页版:
pnpm dlx shadcn@latest add https://yiui.dev/r/auth-05-panel.json--dry-run 或 --diff 看清差异再装。最小依赖清单:
| 类别 | 内容 |
|---|---|
| 文件(共 11 个) | auth-05-panel.tsx、parts.tsx、panels.tsx、use-auth-05.ts、form-parts.tsx、use-auth-form.ts、mask.ts、config.ts、types.ts(都在 components/auth-05/ 下),hooks/use-controllable-state.ts,以及方条开关用到的 lib/flow-tone.ts |
| shadcn 组件 | avatar、button、input、input-otp |
| 本站组件 | 方条开关(toggle-switch,「记住此设备」用它) |
| npm 包 | motion、lucide-react、@base-ui/react、class-variance-authority、input-otp(后三个随 shadcn 组件和方条开关带入) |
| 样式变量 | --edge(线条颜色,亮暗各一份)和 border-edge 对应的 color-edge 映射,安装时自动写进 globals.css |
和完整安装的区别:
- 不会创建示例页:整页版会在
app/login-verify/page.tsx放一个示例页,auth-05-panel不创建任何app/下的文件。 - 不含整页外壳:没有
auth-05.tsx,也不依赖线条网格(grid-layout)。 - 完整版
auth-05本身就依赖auth-05-panel,所以先装了面板再装整页版,只会补上整页外壳、示例页和线条网格。
关于「记住此设备」:使用方即使传 showRememberDevice={false} 把开关关掉,toggle-switch 和 flow-tone 仍会被装进来,因为 parts.tsx 里引用了它。没有换成官方 switch,因为方条开关整行可点、手柄和高度与站内其他方条控件同一套,换掉会让整页版和面板的外观与其他控件不一致;这部分只多出 2 个文件,没有额外的 npm 包。
线条颜色 --edge 由 auth-05-panel 自己声明,不靠 grid-layout 带入,所以只装面板也有正确的边框颜色,并跟随亮暗模式。
回调约定
onPasskey、onSendCode、onVerify都是返回Promise<void>的函数;成功就正常返回,失败就抛出Error,其message会显示给用户。onPasskey抛出name === "NotAllowedError"的错误会被当作「已取消」,而不是错误。navigator.credentials.get在用户关闭弹窗、超时或拒绝时都抛这个错误,浏览器不区分这几种情况。- 回调进行中,同一操作不会被重复触发(自动提交和回车共用同一把锁)。
onVerify的method只会是totp、sms、recovery。
接入通行密钥
组件不直接调用 navigator.credentials,WebAuthn 由你在 onPasskey 里实现。下面只是示意:challenge 必须由服务端生成并校验,不要在客户端生成。
<Auth05
account={account}
onPasskey={async () => {
// 1. 向服务端要 challenge 和允许的凭据列表(服务端要把 challenge 记下来)
const options = await fetch("/api/passkey/options", { method: "POST" })
.then((res) => res.json())
// 2. 唤起系统弹窗;用户取消时这里抛 NotAllowedError,组件会显示「已取消」
const credential = (await navigator.credentials.get({
publicKey: {
challenge: base64urlToBuffer(options.challenge),
rpId: options.rpId,
allowCredentials: options.allowCredentials.map((item: { id: string }) => ({
type: "public-key",
id: base64urlToBuffer(item.id),
})),
userVerification: "preferred",
timeout: 60_000,
},
})) as PublicKeyCredential
// 3. 把签名交给服务端校验,不通过就抛 Error
const res = await fetch("/api/passkey/verify", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(serializeCredential(credential)),
})
if (!res.ok) throw new Error("通行密钥验证失败,请重试")
}}
/>base64urlToBuffer 和 serializeCredential 是你自己的工具函数,也可以用现成的 WebAuthn 库代替。
示例
演示规则(只在页面外壳里显示,不属于组件):
- 通行密钥:1.5 秒后按外壳里选的结果返回,可选「成功 / 取消 / 不支持 / 失败」。
- 身份验证器 App:
123456成功,其他验证码提示「验证码错误」。 - 短信验证码:发送有 1 秒延迟,验证码
123456。 - 恢复码:
ABCD-1234成功,其他提示「恢复码无效或已使用」。 - 外壳顶部的「演示布局」可切到「嵌入」:左边是品牌面板占位,右边是
Auth05Panel,只开身份验证器和恢复码,账号只有用户名,没有记住设备,备用码不格式化(abc_12345成功)。 - 「嵌入·定长码」用后端生成 8 位码的配置:
recoveryCode={{ format: false, uppercase: true, maxLength: 9, placeholder: "XXXX-XXXX" }}。演示后端自己去掉连字符再比较,所以ABCD-1234和ABCD1234都成功;粘贴超过 9 个字符会出现超长错误。
API
已通过第一步认证的账号。
类型Auth05Account可用的验证方式,顺序即「其他验证方式」列表的顺序。@default ["passkey","totp","sms","recovery"]
类型readonly Auth05Method[] | undefined通行密钥验证。在这里调用 WebAuthn;失败时抛出 Error。
name 为 NotAllowedError 的错误会被当作「已取消」,其他错误显示 message。
(() => Promise<void>) | undefined发送短信验证码,失败时抛出 Error。
类型(() => Promise<void>) | undefined验证验证码或恢复码,失败时抛出 Error。
类型((values: { method: Exclude<Auth05Method, "passkey">; code: string; }) => Promise<void>) | undefined验证成功后的通知。
类型((values: { method: Auth05Method; rememberDevice: boolean; }) => void) | undefined点击「切换账号」。
类型(() => void) | undefined「联系管理员」链接地址;不传时不显示这个入口。
类型string | undefined覆盖浏览器能力检测,方便演示和测试;不传时在挂载后检测 PublicKeyCredential。
boolean | undefined验证码位数。@default 6
类型number | undefined短信重发间隔秒数。@default 60
类型number | undefined备用码的格式;不传时为 XXXX-XXXX。
Auth05RecoveryCode | undefined是否显示「记住此设备」开关。后端不支持时设为 false:开关不渲染,onSuccess 里的 rememberDevice 固定为 false。
boolean | undefinedtrue主标题「验证你的身份」用的标题级别。嵌进已有页面时,宿主页通常已有 h1,用 2 或 3 保持层级连续。
类型1 | 2 | 3 | undefinedAuth05 为 1,Auth05Panel 为 2品牌名称和标志。
类型{ name: string; logo?: React.ReactNode; } | undefined覆盖默认中文文案。
类型Partial<{ title: string; description: string; accountSwitch: string; accountLabel: string; alternatives: string; rememberDevice: string; help: string; passkeyName: string; passkeyHint: string; passkeyTitle: string; passkeyDescription: string; passkeyAction: string; passkeyWaiting: string; passkeyWaitingButton: string; passkeyCancelled: string; passkeyUnsupported: string; passkeySuggest: string; totpName: string; totpHint: string; totpTitle: string; totpDescription: string; smsName: string; smsHint: string; smsTitle: string; smsDescription: string; smsSend: string; smsSending: string; smsResend: string; smsCountdown: string; smsSent: string; recoveryName: string; recoveryHint: string; recoveryTitle: string; recoveryDescription: string; recoveryLabel: string; recoveryPlaceholder: string; recoveryNote: string; recoveryError: string; recoveryEmpty: string; recoveryTooLong: string; recoveryTrimmed: string; code: string; verify: string; verifying: string; failed: string; success: string; successDescription: string; successAnnounce: string; }> | undefined受控的当前验证方式。
类型Auth05Method | undefined初始验证方式。@default 第一个可用方式
类型Auth05Method | undefined验证方式变化通知。
类型((method: Auth05Method) => void) | undefined受控的「记住此设备」。
类型boolean | undefined初始的「记住此设备」。@default false
类型boolean | undefined((value: boolean) => void) | undefinedAuth05Panel 的 props 与 Auth05 相同。
- 受控与非受控:
method/defaultMethod/onMethodChange控制当前验证方式;rememberDevice/defaultRememberDevice/onRememberDeviceChange控制「记住此设备 30 天」,默认关闭。 passkeySupported用来覆盖浏览器能力检测,演示和测试时很有用;不传时在挂载后检测window.PublicKeyCredential。account的email、phone、name、username至少一个,显示规则见上面的「账号摘要」;phone是 11 位手机号,组件显示为138 **** 8000;avatar不传时头像显示摘要名字的首字。showRememberDevice默认true;recoveryCode见「备用码格式」,超长与丢弃提示的文案是labels.recoveryTooLong、labels.recoveryTrimmed。headingLevel是主标题「验证你的身份」的级别:Auth05默认1,Auth05Panel默认2。嵌进已有页面时,宿主页通常已经有 h1,按页面层级设成 2 或 3。codeLength默认 6 位,resendSeconds默认 60 秒。helpHref提供「都无法使用?联系管理员」的链接,不传就不显示这个入口。labels覆盖Auth05Labels,每种方式的名称、说明、标题分别是<method>Name、<method>Hint、<method>Title。brand接收name和可选logo。- 导出组件:
Auth05(整页)、Auth05Panel(只有验证内容)。导出类型:Auth05Props、Auth05Method、Auth05Account、Auth05RecoveryCode、Auth05Labels。
文件结构:auth-05.tsx 为整页入口,auth-05-panel.tsx 是嵌入用的 Auth05Panel,use-auth-05.ts 放两者共用的状态,parts.tsx 是共用的页面零件(标题、账号摘要、其他方式列表等),panels.tsx 是三种验证面板,form-parts.tsx 封装验证码格子,mask.ts 负责脱敏、账号摘要与恢复码格式,config.ts 管理文案,use-auth-form.ts 管理异步状态,types.ts 定义 API,page.tsx 为安装到项目里的页面入口。auth-05 条目只含 auth-05.tsx 和 page.tsx,其余文件属于 auth-05-panel 条目(auth-05 依赖它)。
根节点 data-slot="auth-05" 暴露 data-method 和 data-state(Auth05Panel 的根节点是 auth-05-content,属性相同);账号摘要为 auth-05-account,主面板为 auth-05-panel,其他方式列表为 auth-05-alternatives。
可访问性
- 切换验证方式或验证成功后,焦点会落在新内容的标题上,Tab 可继续进入控件。
- 「其他验证方式」是一个导航区域里的按钮列表,用 Tab 和回车或空格操作;有请求进行中时列表禁用。
- 通行密钥的状态(等待中、已取消、不支持)放在常驻的
aria-live="polite"区域里;短信已发送、验证成功同样会播报;错误用role="alert"。 - 验证码输入支持整串粘贴、退格和方向键,提供数字键盘和短信自动填充,读屏名称是「验证码,共 6 位」;错误通过
aria-describedby关联,所有格子带错误态。 - 恢复码输入框有可见标签,关闭自动填充,并提示每个码只能用一次;超长错误用
role="alert"并通过aria-describedby关联,有掩码时丢掉多余字符的提示放在role="status"里。 - 倒计时不逐秒播报;过渡动画遵守减少动态效果设置。
注意事项
只提供界面,不负责真实认证:WebAuthn 调用、验证码和恢复码校验、短信发送与限流、会话与设备信任都由服务端和你的回调完成。「记住此设备」只通过 onSuccess 的 rememberDevice 告知你,是否真的信任设备由你决定。通行密钥的支持情况在水合前是未知的,此时主按钮保持可点;确认不支持后才会禁用并给出建议。