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
+146
-19
@@ -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 />} />
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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