feat: 单 owner 认证模型 — 替换注册为一次性设置向导 + Recovery Key + CLI 密码重置

自托管 Life OS 不应有公开注册。改为:
- /setup 一次性初始化向导(创建唯一 owner + 生成 Recovery Key)
- /recover 通过 Recovery Key 重置密码
- /login 未初始化时重定向到 /setup,去掉注册链接
- Better Auth databaseHooks 阻止额外用户注册
- citty CLI: bun run cli auth reset-password
- 删除 /signup 路由
- 新增 system_settings 表存储 recovery key hash
- 修复 drizzle.config.ts 非空断言 + sidebar.tsx cookieStore API
- 更新 AGENTS.md shadcn/ui 组件编辑规则
This commit is contained in:
2026-03-31 18:33:16 +08:00
parent d67aaa723e
commit 830714c94f
16 changed files with 483 additions and 45 deletions
+202
View File
@@ -0,0 +1,202 @@
import { createFileRoute, redirect, useRouter } from '@tanstack/react-router'
import { useState } from 'react'
import { authClient } from '@/server/auth/client'
import { checkInitialized, completeSetup } from '@/server/auth/functions'
export const Route = createFileRoute('/setup' as never)({
beforeLoad: async () => {
const initialized = await checkInitialized()
if (initialized) {
throw redirect({ to: '/login' as never })
}
},
component: SetupPage,
})
function SetupPage() {
const router = useRouter()
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [recoveryKey, setRecoveryKey] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setError('')
if (password !== confirmPassword) {
setError('两次输入的密码不一致')
return
}
if (password.length < 8) {
setError('密码至少需要 8 个字符')
return
}
setLoading(true)
const { error: signUpError } = await authClient.signUp.email({
name,
email,
password,
})
if (signUpError) {
setError(signUpError.message ?? '创建账号失败,请重试')
setLoading(false)
return
}
const result = await completeSetup()
setRecoveryKey(result.recoveryKey)
setLoading(false)
}
const handleCopy = async () => {
if (!recoveryKey) return
await navigator.clipboard.writeText(recoveryKey)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const handleContinue = () => {
router.navigate({ to: '/' as never })
}
if (recoveryKey) {
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center px-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-slate-900 tracking-tight">Kairos</h1>
<p className="text-slate-500 mt-2"></p>
</div>
<div className="bg-white rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.04)] ring-1 ring-slate-100 p-8">
<div className="space-y-5">
<div className="bg-amber-50 ring-1 ring-amber-200 rounded-xl p-4">
<p className="text-sm font-medium text-amber-800 mb-2"></p>
<p className="text-xs text-amber-600">访</p>
</div>
<div className="relative">
<code className="block w-full px-4 py-3 rounded-xl bg-slate-50 ring-1 ring-slate-200 text-slate-700 text-sm font-mono break-all select-all">
{recoveryKey}
</code>
<button
type="button"
onClick={handleCopy}
className="absolute top-2 right-2 px-2.5 py-1 text-xs bg-white ring-1 ring-slate-200 rounded-lg text-slate-600 hover:bg-slate-50 transition-all"
>
{copied ? '已复制' : '复制'}
</button>
</div>
<button
type="button"
onClick={handleContinue}
className="w-full py-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl font-medium transition-all shadow-md shadow-indigo-200 hover:shadow-lg hover:shadow-indigo-300 active:scale-[0.98]"
>
使 Kairos
</button>
</div>
</div>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center px-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-slate-900 tracking-tight">Kairos</h1>
<p className="text-slate-500 mt-2"></p>
</div>
<div className="bg-white rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.04)] ring-1 ring-slate-100 p-8">
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="name" className="block text-sm font-medium text-slate-700 mb-1.5">
</label>
<input
id="name"
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-4 py-3 rounded-xl bg-slate-50 border-0 ring-1 ring-slate-200 focus:ring-2 focus:ring-indigo-500 outline-none transition-all text-slate-700 placeholder:text-slate-400"
placeholder="你的名字"
disabled={loading}
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-slate-700 mb-1.5">
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-3 rounded-xl bg-slate-50 border-0 ring-1 ring-slate-200 focus:ring-2 focus:ring-indigo-500 outline-none transition-all text-slate-700 placeholder:text-slate-400"
placeholder="your@email.com"
disabled={loading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-slate-700 mb-1.5">
</label>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-3 rounded-xl bg-slate-50 border-0 ring-1 ring-slate-200 focus:ring-2 focus:ring-indigo-500 outline-none transition-all text-slate-700 placeholder:text-slate-400"
placeholder="至少 8 个字符"
disabled={loading}
/>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-slate-700 mb-1.5">
</label>
<input
id="confirmPassword"
type="password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-4 py-3 rounded-xl bg-slate-50 border-0 ring-1 ring-slate-200 focus:ring-2 focus:ring-indigo-500 outline-none transition-all text-slate-700 placeholder:text-slate-400"
placeholder="再次输入密码"
disabled={loading}
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl font-medium transition-all shadow-md shadow-indigo-200 disabled:opacity-50 disabled:shadow-none hover:shadow-lg hover:shadow-indigo-300 active:scale-[0.98]"
>
{loading ? '初始化中...' : '开始使用 Kairos'}
</button>
</form>
</div>
</div>
</div>
)
}