Files
MoShiro/web/admin/src/Setup.tsx
T

126 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}