126 lines
5.7 KiB
TypeScript
126 lines
5.7 KiB
TypeScript
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>
|
||
)
|
||
}
|