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

+146 -19
View File
@@ -1,10 +1,13 @@
import { FlaskConical, ListChecks, LogOut, Plus, Radar, Server, Settings } from 'lucide-react'
import { useState, type FormEvent } from 'react'
import { FlaskConical, KeyRound, ListChecks, LogOut, Plus, Radar, Server, Settings } from 'lucide-react'
import { useEffect, useRef, useState, type FormEvent } from 'react'
import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
import { ApiError } from '@shared/api/client'
import { activateSource, getBase, listSources } from '@shared/api/source'
import { describeError, Field, SourceButton, Spinner, ThemeToggle } from '@shared/ui'
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 { PasskeyNotice } from './PasskeyNotice'
import { AgentDetail } from './pages/AgentDetail'
import { Agents } from './pages/Agents'
import { Probes } from './pages/Probes'
@@ -12,9 +15,9 @@ import { System } from './pages/System'
import { Tasks } from './pages/Tasks'
import { Tests } from './pages/Tests'
/** 登录页:左边选后端(这个页面自己所在的,或保存过的,或添加一个),右边都是用户名 + 密码。 */
/** 登录页:左边选后端(这个页面自己所在的,或保存过的,或添加一个),右边选登录方式:账号密码,或者 Passkey(只有页面自己所在的后端有)。 */
function Login() {
const { login } = useAuth()
const { login, loginWithPasskey } = useAuth()
const active = getBase()
const saved = listSources()
const [adding, setAdding] = useState(false)
@@ -47,6 +50,94 @@ function Login() {
}
}
// passkey 绑定在后端的域名上:只有页面自己所在的后端能用,别的后端(跨域)不显示
const canPasskey = passkeySupported() && !adding && !active
// 登录方式:记住上次用的;浏览器不支持或者是别的后端就只有账号密码
const [method, setMethod] = useState<'password' | 'passkey'>(() => {
try {
return localStorage.getItem('moshiro.login_method') === 'passkey' ? 'passkey' : 'password'
} catch {
return 'password'
}
})
const pickMethod = (next: 'password' | 'passkey') => {
setMethod(next)
setError('')
try {
localStorage.setItem('moshiro.login_method', next)
} catch {
// 记不住也只是下次回到默认
}
}
const usingPasskey = canPasskey && method === 'passkey'
// 自动填充只在「账号密码」页有用(它依附在用户名输入框上)
const autofillActive = canPasskey && method === 'password'
// 重新开始自动填充登录(点按钮的弹窗会顶掉它,取消之后要重新挂上)
const [round, setRound] = useState(0)
const retry = () => {
if (retryOnFocus.current) {
retryOnFocus.current = false
setRound((n) => n + 1)
}
}
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)
setError('')
conditional.current?.abort()
await new Promise((resolve) => setTimeout(resolve, 0))
try {
await loginWithPasskey()
} catch (err) {
// 用户取消(NotAllowedError)不当成错误
if (!(err instanceof DOMException && err.name === 'NotAllowedError')) setError(describeError(err))
} finally {
setBusy(false)
setRound((n) => n + 1)
}
}
// passkey 自动填充:点用户名输入框,浏览器会把保存的 passkey(Chrome 里是 Google 密码管理工具)列在下拉里,选一个就直接登录。
// 挑战 5 分钟过期,所以每 4 分钟换一个
useEffect(() => {
if (!autofillActive) return undefined
const abort = new AbortController()
conditional.current = abort
let timer: ReturnType<typeof setTimeout> | undefined
let expired = false // 只有「挑战到期」才重新开始;组件卸载、依赖变化时的取消不能再触发重来(否则会形成循环)
void conditionalSupported().then(async (ok) => {
if (abort.signal.aborted) return
setAutofill(ok ? 'yes' : 'no')
if (!ok) return
timer = setTimeout(() => {
expired = true
abort.abort()
}, 4 * 60_000)
try {
await loginWithPasskey({ conditional: true, signal: abort.signal })
} catch (err) {
if (timer) clearTimeout(timer)
if (abort.signal.aborted) {
if (expired) setRound((n) => n + 1)
return
}
// 取消、被弹窗顶掉、浏览器不允许都不是错误;服务端拒绝(含限流)才显示
if (!(err instanceof DOMException)) {
retryOnFocus.current = true
setError(err instanceof ApiError && err.status === 401 ? '这个 Passkey 在服务器上已经不存在或已失效(可能被删除了),请换一个,或者用密码登录' : describeError(err))
}
}
})
return () => {
abort.abort()
if (timer) clearTimeout(timer)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autofillActive, round])
const rows = [{ base: '', label: location.host }, ...saved.map((x) => ({ base: x.base, label: x.base.replace(/^https?:\/\//i, '') }))]
const title = adding ? '添加后端' : active ? active.replace(/^https?:\/\//i, '') : location.origin
@@ -91,21 +182,56 @@ function Login() {
</aside>
<section className="login-main stack">
<h2>{title}</h2>
{adding ? (
<Field label="后端地址">
<input className="input" name="backend-url" type="text" inputMode="url" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="例如 https://你的域名" autoComplete="off" autoFocus required />
</Field>
{canPasskey ? (
<Segmented
options={[
{ value: 'password' as const, label: '账号密码' },
{ value: 'passkey' as const, label: 'Passkey' },
]}
value={method}
onChange={pickMethod}
/>
) : null}
<Field label="用户名">
<input className="input" name="username" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" autoFocus={!adding} required />
</Field>
<Field label="密码">
<input className="input" name="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" required />
</Field>
{error ? <div className="notice bad">{error}</div> : null}
<button className="btn primary" type="submit" disabled={busy}>
{adding ? '保存并登录' : '登录'}
</button>
{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} />
使用 Passkey 登录
</button>
<p className="faint" style={{ fontSize: 12 }}>
还没有 Passkey?先用「账号密码」登录,到「系统 → 用户 → 我的 Passkey」添加。
</p>
</>
) : (
<>
{adding ? (
<Field label="后端地址">
<input className="input" name="backend-url" type="text" inputMode="url" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="例如 https://你的域名" autoComplete="off" autoFocus required />
</Field>
) : null}
<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>
{error ? <div className="notice bad">{error}</div> : null}
<button className="btn primary" type="submit" disabled={busy}>
{adding ? '保存并登录' : '登录'}
</button>
</>
)}
</section>
</form>
</div>
@@ -164,6 +290,7 @@ export function App() {
</aside>
<div className="main">
<BackendUpdateNotice />
<PasskeyNotice />
<Routes>
<Route path="/" element={<Navigate to="/agents" replace />} />
<Route path="/agents" element={<Agents />} />
+92
View File
@@ -0,0 +1,92 @@
import { KeyRound } from 'lucide-react'
import { useEffect, useState } from 'react'
import { api } from '@shared/api/client'
import { getBase } from '@shared/api/source'
import type { Page, PasskeyDto } from '@shared/api/types'
import { passkeySupported } from '@shared/api/webauthn'
import { Dialog } from '@shared/ui'
import { useAuth } from './auth'
import { PasskeyAddForm } from './pages/PasskeyAdd'
const SNOOZE_KEY = 'moshiro.passkey_nag_until'
const SNOOZE_MS = 7 * 24 * 3600 * 1000
function snoozed(): boolean {
try {
return Number(localStorage.getItem(SNOOZE_KEY) ?? 0) > Date.now()
} catch {
return false
}
}
/**
* 登录之后,如果这个用户还没有 Passkey,提醒他添加:Passkey 登录不用输密码,而且只有 passkey 登录的会话以后能做加节点这类信任操作。
* 只对页面所在的后端提醒(passkey 绑定在后端的域名上);「稍后提醒」7 天内不再出现。
*/
export function PasskeyNotice() {
const { me } = useAuth()
const [missing, setMissing] = useState(false)
const [adding, setAdding] = useState(false)
useEffect(() => {
if (!me?.user_id || getBase() || !passkeySupported() || snoozed()) return
let alive = true
api
.get<Page<PasskeyDto>>('/api/admin/auth/passkeys')
.then((list) => alive && setMissing(list.items.length === 0))
.catch(() => undefined)
return () => {
alive = false
}
}, [me?.user_id])
if (!missing) return null
const later = () => {
try {
localStorage.setItem(SNOOZE_KEY, String(Date.now() + SNOOZE_MS))
} catch {
// 存不了也只是下次还会提醒
}
setMissing(false)
}
return (
<div className="notice" style={{ marginBottom: 16 }}>
<div className="row wrap">
<KeyRound size={16} />
<span>
<b>建议添加 Passkey</b>:用指纹、人脸或安全密钥登录,不用输密码;以后加节点这类敏感操作也需要它。
</span>
<span className="right row">
<button type="button" className="btn small text" onClick={later}>
7 天后再提醒
</button>
<button type="button" className="btn small primary" onClick={() => setAdding(true)}>
现在添加
</button>
</span>
</div>
{adding ? (
<Dialog
title="添加 Passkey"
onClose={() => setAdding(false)}
actions={
<button type="button" className="btn text" onClick={() => setAdding(false)}>
关闭
</button>
}
>
<p className="muted" style={{ marginBottom: 12 }}>
建议至少注册两个(比如这台电脑和手机),其中一个丢了还能登录。也可以随时在「系统 → 用户 → 我的 Passkey」里管理。
</p>
<PasskeyAddForm
compact
onAdded={() => {
setAdding(false)
setMissing(false)
}}
/>
</Dialog>
) : null}
</div>
)
}
+10 -1
View File
@@ -2,12 +2,15 @@ import { createContext, useCallback, useContext, useEffect, useState, type React
import { api, ApiError, loginTo, setCsrf } from '@shared/api/client'
import { getBase, normalizeBase, setSource, setToken } from '@shared/api/source'
import type { LoginResponse, MeDto } from '@shared/api/types'
import { signInWithPasskey } from '@shared/api/webauthn'
interface Auth {
me: MeDto | null
ready: boolean
/** 用户名密码登录。`base` 是新添加的后端地址(登录成功才保存);不给就登录当前连着的后端。 */
login: (username: string, password: string, base?: string) => Promise<void>
/** passkey 登录(只对页面所在的后端;passkey 绑定在后端的域名上)。 */
loginWithPasskey: (opts?: { conditional?: boolean; signal?: AbortSignal }) => Promise<void>
logout: () => Promise<void>
}
@@ -52,6 +55,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setMe(await api.get<MeDto>('/api/admin/auth/me'))
}, [])
const loginWithPasskey = useCallback(async (opts?: { conditional?: boolean; signal?: AbortSignal }) => {
const r = await signInWithPasskey(opts)
setCsrf(r.csrf_token)
setMe(await api.get<MeDto>('/api/admin/auth/me'))
}, [])
const logout = useCallback(async () => {
try {
await api.post('/api/admin/auth/logout')
@@ -64,7 +73,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
}, [])
return <AuthContext.Provider value={{ me, ready, login, logout }}>{children}</AuthContext.Provider>
return <AuthContext.Provider value={{ me, ready, login, loginWithPasskey, logout }}>{children}</AuthContext.Provider>
}
export function useAuth(): Auth {
+57
View File
@@ -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>
)
}
+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>
</>
)
}
+385
View File
@@ -327,6 +327,40 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/admin/auth/passkey/options": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** A challenge for signing in with a passkey (discoverable credential, no username needed) */
post: operations["auth_passkey_options"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/auth/passkey/login": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Sign in with a passkey assertion; same response as the password sign-in */
post: operations["auth_passkey_login"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/releases": {
parameters: {
query?: never;
@@ -362,6 +396,58 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/admin/auth/passkeys": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** The signed-in user's passkeys */
get: operations["auth_passkeys_list"];
put?: never;
/** Register a passkey from a WebAuthn attestation (ES256, user verification required) */
post: operations["auth_passkeys_register"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/auth/passkeys/options": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** A challenge for registering a new passkey (the user must be signed in) */
post: operations["auth_passkeys_options"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/auth/passkeys/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Remove one of your own passkeys */
delete: operations["auth_passkeys_delete"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/auth/me": {
parameters: {
query?: never;
@@ -1574,6 +1660,10 @@ export interface components {
items: components["schemas"]["NodeInfoDto"][];
next_cursor: null | string;
};
PageDtoOfPasskeyDto: {
items: components["schemas"]["PasskeyDto"][];
next_cursor: null | string;
};
PageDtoOfPlanDto: {
items: components["schemas"]["PlanDto"][];
next_cursor: null | string;
@@ -1622,6 +1712,38 @@ export interface components {
values: null | string[];
description: string;
};
PasskeyCreationOptionsDto: {
challenge: string;
rp_id: string;
rp_name: string;
user_id: string;
user_name: string;
exclude_credentials: string[];
/** Format: int32 */
timeout_ms: number | string;
};
PasskeyDto: {
id: string;
name: string;
/** Format: date-time */
created_at: unknown;
/** Format: date-time */
last_used_at: unknown;
transports: string[];
};
PasskeyLoginRequest: {
credential_id: string;
authenticator_data: string;
client_data_json: string;
signature: string;
return_session: null | boolean;
};
PasskeyRequestOptionsDto: {
challenge: string;
rp_id: string;
/** Format: int32 */
timeout_ms: number | string;
};
PatchProbeRequest: {
name: null | string;
};
@@ -1718,6 +1840,13 @@ export interface components {
/** Format: int32 */
queue_length: number | string;
};
RegisterPasskeyRequest: {
name: string;
credential_id: string;
attestation_object: string;
client_data_json: string;
transports: null | string[];
};
RejectedDto: {
id: string;
reason: string;
@@ -2744,6 +2873,86 @@ export interface operations {
};
};
};
auth_passkey_options: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PasskeyRequestOptionsDto"];
};
};
/** @description Too Many Requests */
429: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
};
};
auth_passkey_login: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["PasskeyLoginRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["LoginResponse"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Too Many Requests */
429: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
};
};
releases_list: {
parameters: {
query?: never;
@@ -2871,6 +3080,182 @@ export interface operations {
};
};
};
auth_passkeys_list: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PageDtoOfPasskeyDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
};
};
auth_passkeys_register: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["RegisterPasskeyRequest"];
};
};
responses: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PasskeyDto"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
};
};
auth_passkeys_options: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PasskeyCreationOptionsDto"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
};
};
auth_passkeys_delete: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
};
};
auth_me: {
parameters: {
query?: never;
+3
View File
@@ -40,6 +40,9 @@ export type CreatedJoinTokenDto = Narrow<S['CreatedJoinTokenDto']>
export type ApiTokenDto = Narrow<S['ApiTokenDto']>
export type CreatedApiTokenDto = Narrow<S['CreatedApiTokenDto']>
export type CapabilityCatalogDto = Narrow<S['CapabilityCatalogDto']>
export type PasskeyDto = Narrow<S['PasskeyDto']>
export type PasskeyCreationOptionsDto = Narrow<S['PasskeyCreationOptionsDto']>
export type PasskeyRequestOptionsDto = Narrow<S['PasskeyRequestOptionsDto']>
export type MeDto = Narrow<S['MeDto']>
export type SettingDto = Narrow<S['SettingDto']>
export type UserDto = Narrow<S['UserDto']>
+87
View File
@@ -0,0 +1,87 @@
import { api } from './client'
import type { LoginResponse, PasskeyCreationOptionsDto, PasskeyDto, PasskeyRequestOptionsDto } from './types'
/** 浏览器是否支持在输入框的自动填充里列出 passkey(conditional UI)。 */
export async function conditionalSupported(): Promise<boolean> {
try {
return passkeySupported() && (await PublicKeyCredential.isConditionalMediationAvailable?.()) === true
} catch {
return false
}
}
/**
* passkey 保存在哪里:浏览器和系统决定具体用谁的密码管理器,网站只能给提示。
* `device`:此设备或浏览器账户(Chrome 里是 Google 密码管理工具,也可以是 Windows Hello、iCloud 钥匙串);
* `external`:安全密钥,或者用手机扫码。
*/
export type PasskeyPlace = 'device' | 'external'
/** 浏览器是否支持 WebAuthn(passkey)。 */
export function passkeySupported(): boolean {
return typeof window !== 'undefined' && !!window.PublicKeyCredential && !!navigator.credentials
}
function toBytes(text: string): Uint8Array<ArrayBuffer> {
const padded = text.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(text.length / 4) * 4, '=')
const binary = atob(padded)
const out = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i)
return out
}
function toText(buffer: ArrayBuffer): string {
let binary = ''
for (const byte of new Uint8Array(buffer)) binary += String.fromCharCode(byte)
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
/**
* 用 passkey 登录(可发现凭证:不需要用户名)。服务端设了会话 Cookie;返回登录响应。
* passkey 绑定在后端的域名上:只有和后端同站的页面能用,所以其他后端(跨域)不显示这个入口。
*/
export async function signInWithPasskey(opts: { conditional?: boolean; signal?: AbortSignal } = {}): Promise<LoginResponse> {
const options = await api.post<PasskeyRequestOptionsDto>('/api/admin/auth/passkey/options')
const credential = (await navigator.credentials.get({
// conditional:不弹窗,浏览器在用户名输入框的自动填充里列出已保存的 passkey(Chrome 里就是 Google 密码管理工具里的)
mediation: opts.conditional ? 'conditional' : undefined,
signal: opts.signal,
publicKey: { challenge: toBytes(options.challenge), rpId: options.rp_id, timeout: opts.conditional ? undefined : options.timeout_ms, userVerification: 'required' },
})) as PublicKeyCredential | null
if (!credential) throw new Error('没有选择 passkey')
const response = credential.response as AuthenticatorAssertionResponse
return api.post<LoginResponse>('/api/admin/auth/passkey/login', {
credential_id: credential.id,
authenticator_data: toText(response.authenticatorData),
client_data_json: toText(response.clientDataJSON),
signature: toText(response.signature),
})
}
/** 给当前登录的用户注册一个新 passkey(要求用户验证,ES256)。 */
export async function registerPasskey(name: string, place: PasskeyPlace = 'device'): Promise<PasskeyDto> {
const options = await api.post<PasskeyCreationOptionsDto>('/api/admin/auth/passkeys/options')
const credential = (await navigator.credentials.create({
publicKey: {
challenge: toBytes(options.challenge),
rp: { id: options.rp_id, name: options.rp_name },
user: { id: toBytes(options.user_id), name: options.user_name, displayName: options.user_name },
pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
excludeCredentials: options.exclude_credentials.map((id) => ({ type: 'public-key' as const, id: toBytes(id) })),
authenticatorSelection: { authenticatorAttachment: place === 'device' ? 'platform' : 'cross-platform', residentKey: 'required', requireResidentKey: true, userVerification: 'required' },
attestation: 'none',
timeout: options.timeout_ms,
// 给浏览器的提示(Chrome 128+):优先用设备自带的(含 Google 密码管理工具),或者安全密钥和手机
hints: place === 'device' ? ['client-device'] : ['security-key', 'hybrid'],
} as PublicKeyCredentialCreationOptions,
})) as PublicKeyCredential | null
if (!credential) throw new Error('没有创建 passkey')
const response = credential.response as AuthenticatorAttestationResponse
return api.post<PasskeyDto>('/api/admin/auth/passkeys', {
name,
credential_id: credential.id,
attestation_object: toText(response.attestationObject),
client_data_json: toText(response.clientDataJSON),
transports: response.getTransports?.() ?? [],
})
}
+9
View File
@@ -504,6 +504,15 @@ textarea {
box-shadow: 0 4px 16px color-mix(in srgb, var(--app-shadow), transparent 75%);
}
.toast-title {
font-weight: 700;
margin-bottom: 2px;
}
.toast {
cursor: pointer;
}
.toast.bad {
background: var(--app-bad);
color: var(--app-card);
+14 -5
View File
@@ -104,24 +104,33 @@ interface ToastItem {
id: number
text: string
bad: boolean
title?: string
}
const ToastContext = createContext<(text: string, bad?: boolean) => void>(() => undefined)
/** 提示的可选项:`title` 是加粗的标题行,`ms` 是停留的毫秒数(缺省成功 3 秒、失败 6 秒)。点一下提示可以提前关掉。 */
export interface ToastOptions {
title?: string
ms?: number
}
const ToastContext = createContext<(text: string, bad?: boolean, options?: ToastOptions) => void>(() => undefined)
export function ToastProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<ToastItem[]>([])
const nextId = useRef(1)
const push = useCallback((text: string, bad = false) => {
const push = useCallback((text: string, bad = false, options?: ToastOptions) => {
const id = nextId.current++
setItems((list) => [...list, { id, text, bad }])
window.setTimeout(() => setItems((list) => list.filter((t) => t.id !== id)), bad ? 6000 : 3000)
setItems((list) => [...list, { id, text, bad, title: options?.title }])
window.setTimeout(() => setItems((list) => list.filter((t) => t.id !== id)), options?.ms ?? (bad ? 6000 : 3000))
}, [])
const dismiss = (id: number) => setItems((list) => list.filter((t) => t.id !== id))
return (
<ToastContext.Provider value={push}>
{children}
<div className="toasts" aria-live="polite">
{items.map((t) => (
<div key={t.id} className={`toast ${t.bad ? 'bad' : ''}`}>
<div key={t.id} className={`toast ${t.bad ? 'bad' : ''}`} role="status" onClick={() => dismiss(t.id)} title="点一下关闭">
{t.title ? <div className="toast-title">{t.title}</div> : null}
{t.text}
</div>
))}