refactor: 移除 Recovery Key 机制,简化单 owner 认证流程

Recovery Key 对自托管场景多余 —— owner 必有服务器访问权限,CLI 重置足够。
- 删除 /recover 路由、systemSettings 表、completeSetup/recoverAccount functions
- /setup 创建完直接跳转,去掉 Recovery Key 步骤
- /login 去掉恢复密钥链接
- 修复跨目录相对路径 → @/ 别名(drizzle schema 链除外)
This commit is contained in:
2026-03-31 18:55:13 +08:00
parent 830714c94f
commit d3f2088fc8
8 changed files with 85 additions and 393 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
import type { ModuleMetadata } from '../registry' import type { ModuleMetadata } from '@/modules/registry'
export const bookmarksModule: ModuleMetadata = { export const bookmarksModule: ModuleMetadata = {
id: 'bookmarks', id: 'bookmarks',
+1 -7
View File
@@ -1,4 +1,4 @@
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router' import { createFileRoute, redirect, useRouter } from '@tanstack/react-router'
import { useState } from 'react' import { useState } from 'react'
import { authClient } from '@/server/auth/client' import { authClient } from '@/server/auth/client'
import { checkInitialized, getSession } from '@/server/auth/functions' import { checkInitialized, getSession } from '@/server/auth/functions'
@@ -95,12 +95,6 @@ function LoginPage() {
{loading ? '登录中...' : '登录'} {loading ? '登录中...' : '登录'}
</button> </button>
</form> </form>
<p className="text-center text-sm text-slate-500 mt-6">
<Link to={'/recover' as never} className="text-indigo-600 hover:text-indigo-700 font-medium">
使
</Link>
</p>
</div> </div>
</div> </div>
</div> </div>
-157
View File
@@ -1,157 +0,0 @@
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
import { useState } from 'react'
import { checkInitialized, recoverAccount } from '@/server/auth/functions'
export const Route = createFileRoute('/recover' as never)({
beforeLoad: async () => {
const initialized = await checkInitialized()
if (!initialized) {
throw redirect({ to: '/setup' as never })
}
},
component: RecoverPage,
})
function RecoverPage() {
const router = useRouter()
const [recoveryKey, setRecoveryKey] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [success, setSuccess] = useState(false)
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setError('')
if (newPassword !== confirmPassword) {
setError('两次输入的密码不一致')
return
}
if (newPassword.length < 8) {
setError('密码至少需要 8 个字符')
return
}
setLoading(true)
try {
await recoverAccount({ data: { recoveryKey: recoveryKey.trim(), newPassword } })
setSuccess(true)
} catch (err) {
setError(err instanceof Error ? err.message : '恢复失败,请检查恢复密钥是否正确')
} finally {
setLoading(false)
}
}
if (success) {
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-emerald-50 ring-1 ring-emerald-200 rounded-xl p-4">
<p className="text-sm text-emerald-800">使</p>
</div>
<button
type="button"
onClick={() => router.navigate({ to: '/login' as never })}
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]"
>
</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="recoveryKey" className="block text-sm font-medium text-slate-700 mb-1.5">
</label>
<input
id="recoveryKey"
type="text"
required
value={recoveryKey}
onChange={(e) => setRecoveryKey(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 font-mono text-sm"
placeholder="kairos-recovery-..."
disabled={loading}
/>
</div>
<div>
<label htmlFor="newPassword" className="block text-sm font-medium text-slate-700 mb-1.5">
</label>
<input
id="newPassword"
type="password"
required
value={newPassword}
onChange={(e) => setNewPassword(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 ? '恢复中...' : '重置密码'}
</button>
</form>
<p className="text-center text-sm text-slate-500 mt-6">
<Link to={'/login' as never} className="text-indigo-600 hover:text-indigo-700 font-medium">
</Link>
</p>
</div>
</div>
</div>
)
}
+2 -63
View File
@@ -1,7 +1,7 @@
import { createFileRoute, redirect, useRouter } from '@tanstack/react-router' import { createFileRoute, redirect, useRouter } from '@tanstack/react-router'
import { useState } from 'react' import { useState } from 'react'
import { authClient } from '@/server/auth/client' import { authClient } from '@/server/auth/client'
import { checkInitialized, completeSetup } from '@/server/auth/functions' import { checkInitialized } from '@/server/auth/functions'
export const Route = createFileRoute('/setup' as never)({ export const Route = createFileRoute('/setup' as never)({
beforeLoad: async () => { beforeLoad: async () => {
@@ -21,8 +21,6 @@ function SetupPage() {
const [confirmPassword, setConfirmPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [recoveryKey, setRecoveryKey] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault() e.preventDefault()
@@ -52,67 +50,9 @@ function SetupPage() {
return 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 }) router.navigate({ to: '/' as never })
} }
;<div className="min-h-screen bg-slate-50 flex items-center justify-center px-4">
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="w-full max-w-md">
<div className="text-center mb-8"> <div className="text-center mb-8">
<h1 className="text-4xl font-bold text-slate-900 tracking-tight">Kairos</h1> <h1 className="text-4xl font-bold text-slate-900 tracking-tight">Kairos</h1>
@@ -198,5 +138,4 @@ function SetupPage() {
</div> </div>
</div> </div>
</div> </div>
)
} }
+1 -1
View File
@@ -1,5 +1,5 @@
import * as bookmarks from '@/modules/bookmarks/router' import * as bookmarks from '@/modules/bookmarks/router'
import { os } from '../server' import { os } from '@/server/api/server'
export const router = os.router({ export const router = os.router({
bookmarks, bookmarks,
-72
View File
@@ -1,23 +1,8 @@
import { createServerFn } from '@tanstack/react-start' import { createServerFn } from '@tanstack/react-start'
import { getRequestHeaders } from '@tanstack/react-start/server' import { getRequestHeaders } from '@tanstack/react-start/server'
import { hashPassword, verifyPassword } from 'better-auth/crypto'
import { eq } from 'drizzle-orm'
import { z } from 'zod'
import { auth } from '@/server/auth' import { auth } from '@/server/auth'
import * as authSchema from '@/server/auth/schema' import * as authSchema from '@/server/auth/schema'
import { db } from '@/server/db' import { db } from '@/server/db'
import { systemSettings } from '@/server/db/schema/system'
const RECOVERY_KEY_PREFIX = 'kairos-recovery-'
const RECOVERY_KEY_SETTING = 'recovery_key_hash'
function generateRecoveryKey(): string {
const bytes = crypto.getRandomValues(new Uint8Array(32))
const hex = Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
return `${RECOVERY_KEY_PREFIX}${hex}`
}
export const getSession = createServerFn({ method: 'GET' }).handler(async () => { export const getSession = createServerFn({ method: 'GET' }).handler(async () => {
const headers = getRequestHeaders() const headers = getRequestHeaders()
@@ -28,60 +13,3 @@ export const checkInitialized = createServerFn({ method: 'GET' }).handler(async
const users = await db.select({ id: authSchema.user.id }).from(authSchema.user).limit(1) const users = await db.select({ id: authSchema.user.id }).from(authSchema.user).limit(1)
return users.length > 0 return users.length > 0
}) })
export const completeSetup = createServerFn({ method: 'POST' }).handler(async () => {
const headers = getRequestHeaders()
const sessionData = await auth.api.getSession({ headers })
if (!sessionData?.user) {
throw new Error('Not authenticated')
}
const users = await db.select({ id: authSchema.user.id }).from(authSchema.user).limit(2)
if (users.length !== 1) {
throw new Error('Invalid system state')
}
const existing = await db.select().from(systemSettings).where(eq(systemSettings.key, RECOVERY_KEY_SETTING))
if (existing.length > 0) {
throw new Error('Setup already completed')
}
const recoveryKey = generateRecoveryKey()
const hash = await hashPassword(recoveryKey)
await db.insert(systemSettings).values({ key: RECOVERY_KEY_SETTING, value: hash })
return { recoveryKey }
})
const recoverAccountSchema = z.object({
recoveryKey: z.string().min(1),
newPassword: z.string().min(8),
})
export const recoverAccount = createServerFn({ method: 'POST' })
.inputValidator((data: z.input<typeof recoverAccountSchema>) => recoverAccountSchema.parse(data))
.handler(async ({ data }) => {
const stored = await db.select().from(systemSettings).where(eq(systemSettings.key, RECOVERY_KEY_SETTING))
if (stored.length === 0 || !stored[0]) {
throw new Error('Recovery not available')
}
const isValid = await verifyPassword({ hash: stored[0].value, password: data.recoveryKey })
if (!isValid) {
throw new Error('Invalid recovery key')
}
const owner = await db.select().from(authSchema.user).limit(1)
if (owner.length === 0 || !owner[0]) {
throw new Error('No owner found')
}
const newHash = await hashPassword(data.newPassword)
await db.update(authSchema.account).set({ password: newHash }).where(eq(authSchema.account.userId, owner[0].id))
await db.delete(authSchema.session).where(eq(authSchema.session.userId, owner[0].id))
return { success: true }
})
@@ -1,3 +1,2 @@
export * from '../../../modules/bookmarks/schema' export * from '../../../modules/bookmarks/schema'
export * from '../../auth/schema' export * from '../../auth/schema'
export * from './system'
@@ -1,11 +0,0 @@
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core'
export const systemSettings = pgTable('system_settings', {
key: text('key').primaryKey(),
value: text('value').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date()),
})