feat: first-run claim wizard (no default admin, claim code from the backend log); owner list shows account and passkey; container install first [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful

This commit is contained in:
justme committed 2026-10-08 06:47:10 +08:00
1 parent 2ae63653c4
commit f22fdf8b68
31 files changed
+799 -49

No files matched your search

+125
View File
@@ -0,0 +1,125 @@
import { Check, KeyRound, ShieldCheck, UserPlus } from 'lucide-react'
import { useState, type FormEvent } from 'react'
import { ApiError } from '@shared/api/client'
import { passkeySupported } from '@shared/api/webauthn'
import { describeError, Field } from '@shared/ui'
import { useAuth } from './auth'
import { Trust } from './pages/Trust'
import { PasskeyAddForm } from './pages/PasskeyAdd'
const STEPS = [
{ icon: UserPlus, label: '认领面板' },
{ icon: KeyRound, label: '注册 Passkey' },
{ icon: ShieldCheck, label: '建立信任根' },
]
/**
* 认领向导:面板里还没有任何用户时出现。没有默认账户,管理员的用户名和密码由部署的人自己填;
* 谁先打开页面谁就能当管理员,所以第一步要后端日志里的认领码(只有部署的人拿得到)。
* 后两步是引导,不是门槛:之后随时可以在「用户 → 我的 Passkey」和「系统 → 信任」里补,没有 passkey 也能用密码登录管理。
*/
export function Setup() {
const { me, completeSetup, finishSetup } = useAuth()
const [step, setStep] = useState(me ? 1 : 0)
const [trusted, setTrusted] = useState(false)
return (
<div className="login">
<div className="card stack" style={{ maxWidth: 640, width: '100%' }}>
<div>
<h1>MoShiro</h1>
<p className="muted">第一次打开:先认领这个面板——建好管理员、Passkey 和信任根。</p>
</div>
<ol className="row" style={{ gap: 16, listStyle: 'none', padding: 0, margin: 0, flexWrap: 'wrap' }}>
{STEPS.map((s, i) => (
<li key={s.label} className="row" style={{ gap: 6, opacity: i === step ? 1 : 0.55, fontWeight: i === step ? 600 : 400 }}>
{i < step ? <Check size={16} /> : <s.icon size={16} />}
{i + 1}. {s.label}
</li>
))}
</ol>
{step === 0 ? <Account onDone={() => setStep(1)} complete={completeSetup} /> : null}
{step === 1 ? (
<div className="stack">
<p className="muted">
Passkey 是整个系统的根:它给「操作员集合」签名,节点只认从它出发的信任根,以后 web ssh 等敏感操作也只认它。
<b>不注册 Passkey、不建立信任根就无法使用这些功能:添加不了 Agent 和 Probe,只能进入面板做基础管理</b>,这是架构如此,面板自己没法替你代签。
{passkeySupported() ? '' : ' 这个浏览器不支持 Passkey(需要 HTTPS 或 localhost)。'}
</p>
<PasskeyAddForm compact onAdded={() => setStep(2)} />
<div>
<button type="button" className="btn text" onClick={finishSetup}>
跳过,只进入面板做基础管理
</button>
</div>
</div>
) : null}
{step === 2 ? (
<div className="stack">
<Trust onInitialized={() => setTrusted(true)} />
<div>
<button type="button" className="btn primary" onClick={finishSetup}>
{trusted ? '完成,进入面板' : '跳过,只进入面板做基础管理(之后到「系统 → 信任」建立)'}
</button>
</div>
</div>
) : null}
</div>
</div>
)
}
function Account({ complete, onDone }: { complete: (code: string, username: string, password: string) => Promise<void>; onDone: () => void }) {
const [code, setCode] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const submit = async (e: FormEvent) => {
e.preventDefault()
if (password !== confirm) {
setError('两次输入的密码不一样')
return
}
setBusy(true)
setError('')
try {
await complete(code, username, password)
onDone()
} catch (err) {
if (err instanceof ApiError && err.status === 401) setError('认领码不对:它写在后端的日志里(journalctl -u moshiro-backend,或 docker logs)')
else if (err instanceof ApiError && err.status === 409) setError('这个面板已经被认领过了,请直接登录(刷新页面)')
else setError(describeError(err))
} finally {
setBusy(false)
}
}
return (
<form className="stack" onSubmit={submit}>
<Field label="认领码" hint="用来证明你是部署这个面板的人(防止别人抢先认领它)。首次启动时写在后端日志里(journalctl -u moshiro-backend 或 docker logs),或者部署时用 MoShiro__ClaimCode 指定。只有部署的人拿得到。">
<input className="input mono" value={code} onChange={(e) => setCode(e.target.value)} placeholder="XXXX-XXXX-XXXX-XXXX" autoComplete="off" autoFocus required />
</Field>
<Field label="管理员用户名" hint="3–32 个字符:字母、数字、. _ -">
<input className="input" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" required />
</Field>
<Field label="密码" hint="至少 12 个字符">
<input className="input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" required />
</Field>
<Field label="再输一遍密码">
<input className="input" type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} autoComplete="new-password" required />
</Field>
{error ? <div className="notice bad">{error}</div> : null}
<div>
<button className="btn primary" type="submit" disabled={busy}>
认领面板
</button>
</div>
</form>
)
}