feat!: genesis-pinned operator set, server identity with an encrypted control link, key-less join codes; release key continuity
ci/woodpecker/tag/gitea-release Pipeline is pending
ci/woodpecker/tag/linux-release Pipeline failed
ci/woodpecker/tag/windows-release Pipeline was canceled

Join codes need no passkey touch and carry only public values (genesis
fingerprint, owner credentials, installer hashes). Nodes pin the genesis and
owners at enrollment and only follow passkey-signed operator set versions.
The panel's server key is listed in the set; pinned nodes require its
signature and run the control link under AES-256-GCM. Password sessions carry
no capabilities. The updater adopts a next_key from a signed manifest.

Bump version to 0.2.0. [CI SKIP]
This commit is contained in:
justme committed 2026-10-08 03:21:44 +08:00
1 parent 9bb6841dce
commit a2f88a344b
89 files changed
+2145 -1195

No files matched your search

+3
View File
@@ -9,6 +9,7 @@ import { useAuth } from './auth'
import { BackendUpdateNotice } from './BackendUpdateNotice'
import { ElevationBar } from './ElevationBar'
import { PasskeyNotice } from './PasskeyNotice'
import { PasskeyOnly } from './PasskeyOnly'
import { AgentDetail } from './pages/AgentDetail'
import { Agents } from './pages/Agents'
import { Probes } from './pages/Probes'
@@ -242,6 +243,8 @@ export function App() {
)
}
if (!me) return <Login />
// 密码登录的会话没有任何能力:只给它添加 Passkey 的页面
if (me.kind === 'session' && me.auth_method === 'password') return <PasskeyOnly />
return (
<div className="shell">
+61
View File
@@ -0,0 +1,61 @@
import { KeyRound, LogOut } 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 { useAuth } from './auth'
import { PasskeyAddForm } from './pages/PasskeyAdd'
/**
* 用密码登录的会话没有任何管理能力,只能做一件事:添加 Passkey。
* 之后要用 Passkey 登录才能管理节点、令牌和设置——密码泄露最多只能让人给你的账号加一个 Passkey 的机会(还要你已有的 Passkey 批准第二个)。
*/
export function PasskeyOnly() {
const { me, logout } = useAuth()
const [count, setCount] = useState<number | null>(null)
const load = () => {
api
.get<Page<PasskeyDto>>('/api/admin/auth/passkeys')
.then((list) => setCount(list.items.length))
.catch(() => setCount(null))
}
useEffect(load, [])
const remote = !!getBase()
return (
<div className="login">
<div className="card stack" style={{ maxWidth: 520 }}>
<div>
<h1>MoShiro</h1>
<p className="muted">
{me?.username},你是用密码登录的。
</p>
</div>
<div className="notice">用密码登录的会话不能管理任何东西,只能添加 Passkey。添加之后退出,用 Passkey 重新登录。</div>
{remote ? (
<div className="notice warn">这是另一个后端:Passkey 绑定在后端自己的域名上,请直接打开那个后端的管理端地址再登录。</div>
) : !passkeySupported() ? (
<div className="notice warn">这个浏览器不支持 Passkey(需要 HTTPS 或 localhost)。</div>
) : count === 0 || count === null ? (
<div className="stack">
<div className="row" style={{ gap: 8 }}>
<KeyRound size={16} />
<b>添加你的第一个 Passkey</b>
</div>
<PasskeyAddForm compact onAdded={load} />
</div>
) : (
<div className="notice">你已经有 {count} 个 Passkey。退出后用 Passkey 登录即可。</div>
)}
<div>
<button type="button" className="btn primary" onClick={() => void logout()}>
<LogOut size={16} />
退出,用 Passkey 登录
</button>
</div>
</div>
</div>
)
}
+10 -2
View File
@@ -7,6 +7,7 @@ import { fmtAgo, fmtTime } from '@shared/format'
import { useQuery } from '@shared/hooks'
import { NodeTag, useNodeInfos } from '@shared/nodes'
import { Chip, Confirm, Dialog, Dot, Empty, ErrorNotice, Field, Segmented, Spinner, useAction } from '@shared/ui'
import { OwnerPicker } from './OwnerPicker'
import { JoinTokenCreated } from './JoinTokenCreated'
export const STATUS: Record<string, { label: string; tone: 'ok' | 'warn' | 'bad' }> = {
@@ -33,6 +34,7 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated
const [maxUses, setMaxUses] = useState(1)
const [hours, setHours] = useState(24)
const [created, setCreated] = useState<CreatedJoinTokenDto>()
const [controllers, setControllers] = useState<string[] | null>(null)
if (created) return <JoinTokenCreated created={created} kind="Agent" onClose={onClose} />
@@ -57,6 +59,7 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated
auto_approve: autoApprove,
max_uses: maxUses,
expires_in_hours: hours,
controllers: controllers ?? undefined,
})
setCreated(token)
onCreated()
@@ -69,7 +72,7 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated
}
>
<p className="muted" style={{ marginBottom: 16 }}>
添加 Agent 就是创建一个一次性的加入令牌:在目标 VPS 上运行安装命令,Agent 会用它注册并出现在列表里。
添加 Agent 就是创建一个加入令牌:在目标 VPS 上运行安装命令,Agent 会用它注册并出现在列表里。一个令牌可以装任意多台(可用次数不设上限)。
</p>
<div className="form-grid">
<div className="wide">
@@ -83,11 +86,16 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated
</Field>
</div>
<Field label="可用次数">
<input className="input" type="number" min={1} max={100} value={maxUses} onChange={(e) => setMaxUses(Number(e.target.value))} />
<input className="input" type="number" min={1} max={100000} value={maxUses} onChange={(e) => setMaxUses(Number(e.target.value))} />
</Field>
<Field label="有效期(小时)">
<input className="input" type="number" min={1} max={720} value={hours} onChange={(e) => setHours(Number(e.target.value))} />
</Field>
<div className="wide">
<Field label="属主(passkey)" hint="允许授权这个节点 web ssh 等敏感操作的 Passkey;只是选,不用触碰。装上后固定在节点本地,面板改不了。">
<OwnerPicker value={controllers} onChange={setControllers} />
</Field>
</div>
<div className="wide">
<label className="switch">
<input type="checkbox" checked={autoApprove} onChange={(e) => setAutoApprove(e.target.checked)} />
+54 -57
View File
@@ -105,66 +105,63 @@ export function CapabilityPicker({ catalog, mine, value, onChange }: { catalog:
const allOn = all.length > 0 && all.every((id) => value.has(id))
return (
<div className="stack" style={{ gap: 10 }}>
<div className="row wrap" style={{ gap: 6 }}>
<span className="muted">预设:</span>
{catalog.presets.map((p) => (
<button key={p.id} type="button" className="btn small" onClick={() => set(p.capabilities.filter(selectable))}>
{PRESETS[p.id] ?? p.id}
<div>
<div className="cap-toolbar stack" style={{ gap: 8 }}>
<div className="row wrap" style={{ gap: 6 }}>
<span className="muted">预设:</span>
{catalog.presets.map((p) => (
<button key={p.id} type="button" className="btn small" onClick={() => set(p.capabilities.filter(selectable))}>
{PRESETS[p.id] ?? p.id}
</button>
))}
<input className="input" style={{ maxWidth: 220, marginLeft: 'auto' }} placeholder="搜索能力" value={query} onChange={(e) => setQuery(e.target.value)} />
</div>
<div className="row wrap" style={{ gap: 12 }}>
<Check checked={allOn} partial={value.size > 0} onChange={(on) => set(on ? all : [])} label={<b>全选</b>} />
<button type="button" className="btn small text" onClick={() => set(all.filter((id) => !value.has(id)))}>
反选
</button>
))}
<input className="input" style={{ maxWidth: 200, marginLeft: 'auto' }} placeholder="搜索能力" value={query} onChange={(e) => setQuery(e.target.value)} />
<span className="muted">已选 {value.size} / {all.length} 项</span>
</div>
</div>
<div className="row wrap" style={{ gap: 12 }}>
<Check checked={allOn} partial={value.size > 0} onChange={(on) => set(on ? all : [])} label={<b>全选</b>} />
<button type="button" className="btn small text" onClick={() => set(all.filter((id) => !value.has(id)))}>
反选
</button>
<span className="muted">已选 {value.size} 项</span>
</div>
{catalog.groups.map((g) => {
const rows = g.capabilities.filter((c) => visible(c.id))
if (rows.length === 0) return null
const own = g.capabilities.filter((c) => selectable(c.id))
const on = own.filter((c) => value.has(c.id))
return (
<div key={g.id} className="card" style={{ padding: 10 }}>
<Check
checked={own.length > 0 && on.length === own.length}
partial={on.length > 0}
disabled={own.length === 0}
onChange={(next) => (next ? set([...value, ...own.map((c) => c.id)]) : onChange(new Set([...value].filter((id) => !own.some((c) => c.id === id)))))}
label={<b>{GROUPS[g.id] ?? g.id}</b>}
/>
<div className="stack" style={{ gap: 4, marginTop: 6, paddingLeft: 22 }}>
{rows.map((c) => (
<div key={c.id} className="row wrap" style={{ gap: 8 }}>
<Check
checked={value.has(c.id)}
disabled={!selectable(c.id)}
onChange={(next) => toggle(c.id, next)}
label={
<>
{NAMES[c.id]?.[0] ?? c.id}
<span className="mono faint" style={{ marginLeft: 6 }}>
{c.id}
</span>
</>
}
/>
{c.trust ? (
<Chip tone="warn">
<KeyRound size={12} /> 信任类
</Chip>
) : null}
{c.reserved ? <Chip>预留</Chip> : null}
{NAMES[c.id]?.[1] ? <span className="faint">{NAMES[c.id]![1]}</span> : null}
</div>
))}
<div className="cap-grid">
{catalog.groups.map((g) => {
const rows = g.capabilities.filter((c) => visible(c.id))
if (rows.length === 0) return null
const own = g.capabilities.filter((c) => selectable(c.id))
const on = own.filter((c) => value.has(c.id))
return (
<div key={g.id} className="cap-group">
<Check
checked={own.length > 0 && on.length === own.length}
partial={on.length > 0}
disabled={own.length === 0}
onChange={(next) => (next ? set([...value, ...own.map((c) => c.id)]) : onChange(new Set([...value].filter((id) => !own.some((c) => c.id === id)))))}
label={<b>{GROUPS[g.id] ?? g.id}</b>}
/>
<div style={{ marginTop: 6 }}>
{rows.map((c) => (
<div key={c.id} className="cap-row">
<div className="row" style={{ gap: 8 }}>
<Check checked={value.has(c.id)} disabled={!selectable(c.id)} onChange={(next) => toggle(c.id, next)} label={NAMES[c.id]?.[0] ?? c.id} />
{c.trust ? (
<Chip tone="warn">
<KeyRound size={12} /> 信任类
</Chip>
) : null}
{c.reserved ? <Chip>预留</Chip> : null}
</div>
<div className="cap-meta faint">
<span className="mono">{c.id}</span>
{NAMES[c.id]?.[1] ?? ''}
</div>
</div>
))}
</div>
</div>
</div>
)
})}
)
})}
</div>
</div>
)
}
+3 -9
View File
@@ -1,6 +1,6 @@
import type { CreatedJoinTokenDto } from '@shared/api/types'
import { fmtTime } from '@shared/format'
import { CommandTabs, CopyButton, Dialog, Field, guessPlatform } from '@shared/ui'
import { CommandTabs, Dialog, Field, guessPlatform } from '@shared/ui'
/**
* 加入令牌创建之后的对话框:Agent 和 Probe 共用同一份,一行命令装好。
@@ -19,10 +19,10 @@ export function JoinTokenCreated({ created, kind, onClose }: { created: CreatedJ
}
>
<div className="stack">
<div className="notice warn">命令里的代码只显示这一次,请现在就复制。</div>
<div className="notice warn">命令里的代码只显示这一次,请现在就复制。命令里只有公开的值(面板地址、一次性码、信任根指纹和属主),没有任何私钥。</div>
<Field
label={`在要加入的机器上运行(${kind})`}
hint="需要 Docker 或 Podman。命令里已经填好面板地址和一次性代码,节点名字默认取主机名;身份和配置存在命名卷里,重启、换新镜像重建容器都不用再给参数。名字想自己定,或者提示名字被占用,在命令里加上 MOSHIRO_NAME=名字(Linux 和 macOS 写在 bash 或 zsh 前面,Windows 先执行 $env:MOSHIRO_NAME='名字')。"
hint="需要 Docker 或 Podman。命令会先下载安装脚本并核对它的 SHA-256,不一致就不执行;节点名字默认取主机名;身份和配置存在命名卷里,重启、换新镜像重建容器都不用再给参数。名字想自己定,或者提示名字被占用,在命令里加上 MOSHIRO_NAME=名字(Linux 和 macOS 写在 bash 或 zsh 前面,Windows 先执行 $env:MOSHIRO_NAME='名字')。"
>
<CommandTabs
storageKey="moshiro.install_tab"
@@ -34,12 +34,6 @@ export function JoinTokenCreated({ created, kind, onClose }: { created: CreatedJ
]}
/>
</Field>
<Field label="代码">
<pre className="code">{created.plaintext}</pre>
<div>
<CopyButton text={created.plaintext} label="复制代码" />
</div>
</Field>
<p className="muted">
{kind === 'Agent' ? (created.token.auto_approve ? '使用它注册的 Agent 会自动启用。' : '使用它注册的 Agent 需要你在列表里审批之后才会启用。') : ''}
可用 {created.token.max_uses} 次,{fmtTime(created.token.expires_at)} 过期。
+60
View File
@@ -0,0 +1,60 @@
import { useEffect, useState } from 'react'
import { api } from '@shared/api/client'
import type { Page, PasskeyDto, TrustStateDto } from '@shared/api/types'
/**
* 选这个节点的属主:允许授权它 extension 会话(web ssh 等,以后)的 passkey。只是选,不需要触碰 Passkey:
* 选定的凭证 id 会写进安装命令,节点装上时固定在本地,之后面板改不了。默认是操作员集合里的全部。
* 还没有建立信任根时不显示选项,只提示去建立。
*/
export function OwnerPicker({ value, onChange }: { value: string[] | null; onChange: (next: string[]) => void }) {
const [trust, setTrust] = useState<TrustStateDto>()
const [own, setOwn] = useState<PasskeyDto[]>([])
useEffect(() => {
let alive = true
api
.get<TrustStateDto>('/api/admin/trust')
.then((t) => {
if (!alive) return
setTrust(t)
if (value === null) onChange(t.operators ?? [])
})
.catch(() => undefined)
api
.get<Page<PasskeyDto>>('/api/admin/auth/passkeys')
.then((list) => alive && setOwn(list.items))
.catch(() => undefined)
return () => {
alive = false
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
if (!trust) return null
if (!trust.initialized) {
return <div className="notice warn">还没有建立信任根:这个节点装上后不会固定属主,也不会校验面板的操作员集合。先到「系统 → 信任」用 Passkey 建立。</div>
}
const chosen = new Set(value ?? [])
const name = (id: string) => own.find((p) => p.id === id)?.name ?? `其他账号的 Passkey(${id.slice(0, 8)}…)`
return (
<div className="stack" style={{ gap: 4 }}>
{(trust.operators ?? []).map((id) => (
<label key={id} className="switch">
<input
type="checkbox"
checked={chosen.has(id)}
onChange={(e) => {
const next = new Set(chosen)
if (e.target.checked) next.add(id)
else next.delete(id)
onChange([...next])
}}
/>
{name(id)}
</label>
))}
</div>
)
}
+8
View File
@@ -9,6 +9,7 @@ import { NodeTag, useNodeInfos } from '@shared/nodes'
import { IPV6_STATES } from '@shared/ipVersions'
import { Chip, Confirm, Dialog, Empty, ErrorNotice, Field, Spinner, useAction } from '@shared/ui'
import { JoinTokenCreated } from './JoinTokenCreated'
import { OwnerPicker } from './OwnerPicker'
/**
* 线路:哪个 Probe 连哪个 Agent。自动连接开着时所有 Probe 和 Agent 两两连,关着时只连手动选中的;
@@ -96,6 +97,7 @@ function AddProbeDialog({ onClose, onCreated }: { onClose: () => void; onCreated
const [note, setNote] = useState('')
const [hours, setHours] = useState(24)
const [created, setCreated] = useState<CreatedJoinTokenDto>()
const [controllers, setControllers] = useState<string[] | null>(null)
if (created) return <JoinTokenCreated created={created} kind="Probe" onClose={onClose} />
@@ -120,6 +122,7 @@ function AddProbeDialog({ onClose, onCreated }: { onClose: () => void; onCreated
auto_approve: true,
max_uses: 1,
expires_in_hours: hours,
controllers: controllers ?? undefined,
})
setCreated(token)
onCreated()
@@ -143,6 +146,11 @@ function AddProbeDialog({ onClose, onCreated }: { onClose: () => void; onCreated
<Field label="有效期(小时)">
<input className="input" type="number" min={1} max={720} value={hours} onChange={(e) => setHours(Number(e.target.value))} />
</Field>
<div className="wide">
<Field label="属主(passkey)" hint="允许授权这个节点 web ssh 等敏感操作的 Passkey;只是选,不用触碰。装上后固定在节点本地,面板改不了。">
<OwnerPicker value={controllers} onChange={setControllers} />
</Field>
</div>
</div>
</Dialog>
)
+5 -2
View File
@@ -9,8 +9,9 @@ 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'
import { Trust } from './Trust'
type Tab = 'settings' | 'versions' | 'tokens' | 'users' | 'audit' | 'events' | 'raw' | 'analysis'
type Tab = 'settings' | 'versions' | 'tokens' | 'users' | 'trust' | 'audit' | 'events' | 'raw' | 'analysis'
const RESTART_KEYS = ['fleet.restart_enabled', 'fleet.restart_hour', 'fleet.restart_minute', 'fleet.restart_utc_offset_minutes']
@@ -406,6 +407,7 @@ function Tokens() {
{creating ? (
<Dialog
title="创建 API 令牌"
wide
onClose={() => setCreating(false)}
actions={
<>
@@ -832,6 +834,7 @@ export function System() {
{ value: 'versions' as const, label: '版本' },
{ value: 'tokens' as const, label: 'API 令牌' },
{ value: 'users' as const, label: '用户' },
{ value: 'trust' as const, label: '信任' },
{ value: 'audit' as const, label: '审计' },
{ value: 'events' as const, label: '事件' },
{ value: 'raw' as const, label: '原始数据' },
@@ -840,7 +843,7 @@ export function System() {
value={tab}
onChange={setTab}
/>
{tab === 'settings' ? <Settings /> : tab === 'versions' ? <Versions /> : tab === 'tokens' ? <Tokens /> : tab === 'users' ? <Users /> : tab === 'audit' ? <Audit /> : tab === 'events' ? <Events /> : tab === 'raw' ? <Raw /> : <Analysis />}
{tab === 'settings' ? <Settings /> : tab === 'versions' ? <Versions /> : tab === 'tokens' ? <Tokens /> : tab === 'users' ? <Users /> : tab === 'trust' ? <Trust /> : tab === 'audit' ? <Audit /> : tab === 'events' ? <Events /> : tab === 'raw' ? <Raw /> : <Analysis />}
</div>
</>
)
+110
View File
@@ -0,0 +1,110 @@
import { ShieldCheck } from 'lucide-react'
import { api } from '@shared/api/client'
import type { TrustStateDto } from '@shared/api/types'
import { signStatement } from '@shared/api/webauthn'
import { useQuery } from '@shared/hooks'
import { CopyButton, ErrorNotice, Spinner, useAction, useToast } from '@shared/ui'
/**
* 信任根:你的 Passkey 是整个系统的根。节点安装时把下面这个指纹和属主(passkey)固定在本地,之后只认从它出发、由你的 Passkey 签过名的
* 操作员集合,面板自己改不了,也签不出新的版本。
*/
export function Trust() {
const trust = useQuery(() => api.get<TrustStateDto>('/api/admin/trust'), [])
const { busy, run } = useAction()
const toast = useToast()
const state = trust.data
const sign = async () => {
if (!state?.proposal) return
const proposal = state.proposal
const ok = await run(async () => {
const signed = await signStatement(proposal.statement)
await api.post('/api/admin/trust', signed)
})
if (ok) {
toast(state.initialized ? `操作员集合已更新到第 ${proposal.version} 版,会随名册下发给节点。` : '信任根已建立。现在可以添加 Agent 和 Probe 了。', false, { title: '已用 Passkey 签名', ms: 8000 })
trust.reload()
}
}
return (
<div className="card">
<ErrorNotice error={trust.error} />
{!state ? (
<Spinner />
) : !state.initialized ? (
<div className="stack">
<div className="row" style={{ gap: 8 }}>
<ShieldCheck size={18} />
<b>还没有建立信任根</b>
</div>
<p className="muted">
建立之后,新装的节点会把你的信任根指纹和属主固定在本地:以后 web ssh 等敏感操作只认你的 Passkey,Backend 被攻破也改不了。添加节点本身不需要触碰 Passkey。
这一步会把账号下已有的 Passkey 登记为操作员,并需要你触碰两次 Passkey(确认身份、给集合签名)。
</p>
{state.proposal ? (
<div>
<button type="button" className="btn primary" disabled={busy} onClick={() => void sign()}>
用 Passkey 建立信任根
</button>
</div>
) : (
<div className="notice warn">先在「用户 → 我的 Passkey」添加一个 Passkey,才能建立信任根。</div>
)}
</div>
) : (
<div className="stack">
<div className="row" style={{ gap: 8 }}>
<ShieldCheck size={18} />
<b>信任根已建立</b>
<span className="muted">操作员集合第 {state.version} 版</span>
</div>
<div>
<div className="muted" style={{ marginBottom: 4 }}>
创世指纹(安装命令里的 MOSHIRO_GENESIS,节点把它固定下来)
</div>
<pre className="code" style={{ wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}>
{state.genesis}
</pre>
<CopyButton text={state.genesis ?? ''} label="复制指纹" />
</div>
<div>
<div className="muted" style={{ marginBottom: 4 }}>
服务器身份密钥(面板用它向节点证明自己;登记进操作员集合之后,节点只认带这把密钥签名的服务器,链路用应用层加密)
{state.server_key_listed ? ':已登记' : ':还没有登记,需要签名更新操作员集合'}
</div>
<pre className="code" style={{ wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}>{state.server_key}</pre>
</div>
<div>
<div className="muted" style={{ marginBottom: 4 }}>
安装脚本的 SHA-256(命令会先核对它再执行){state.install_matches_release === true ? ':与 CI 签名的发布清单一致' : state.install_matches_release === false ? ':与 CI 签名的发布清单不一致' : ':发布清单里还没有记录'}
</div>
{state.install_matches_release === false ? <div className="notice bad">这个面板提供的安装脚本和 CI 构建时记下的不一样,先不要用它装节点,去发布页核对。</div> : null}
<pre className="code" style={{ wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}>install.sh {state.install_sh_sha256}</pre>
<pre className="code" style={{ wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}>install.ps1 {state.install_ps1_sha256}</pre>
</div>
{state.in_sync ? (
<p className="muted">操作员集合和账号下的 Passkey 一致。</p>
) : (
<div className="notice warn stack">
<div>
账号下的 Passkey(或站点域名)和生效的操作员集合不一致,比如新添加或删除了 Passkey。需要用一把<b>现有操作员的 Passkey</b>签一个新版本,节点才会采用。
</div>
<div>
<b>注意</b>:从集合里去掉一把 Passkey 之后,由它批准过的节点会被固定了信任根的对端拒绝。
</div>
{state.proposal ? (
<div>
<button type="button" className="btn primary" disabled={busy} onClick={() => void sign()}>
签名更新到第 {state.proposal.version} 版
</button>
</div>
) : null}
</div>
)}
</div>
)}
</div>
)
}