refactor: 移除 Recovery Key 机制,简化单 owner 认证流程
Recovery Key 对自托管场景多余 —— owner 必有服务器访问权限,CLI 重置足够。 - 删除 /recover 路由、systemSettings 表、completeSetup/recoverAccount functions - /setup 创建完直接跳转,去掉 Recovery Key 步骤 - /login 去掉恢复密钥链接 - 修复跨目录相对路径 → @/ 别名(drizzle schema 链除外)
This commit is contained in:
@@ -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,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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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,151 +50,92 @@ 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) {
|
<div className="w-full max-w-md">
|
||||||
return (
|
<div className="text-center mb-8">
|
||||||
<div className="min-h-screen bg-slate-50 flex items-center justify-center px-4">
|
<h1 className="text-4xl font-bold text-slate-900 tracking-tight">Kairos</h1>
|
||||||
<div className="w-full max-w-md">
|
<p className="text-slate-500 mt-2">初始化你的人生操作系统</p>
|
||||||
<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>
|
</div>
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
<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="min-h-screen bg-slate-50 flex items-center justify-center px-4">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
<div className="w-full max-w-md">
|
<div>
|
||||||
<div className="text-center mb-8">
|
<label htmlFor="name" className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||||
<h1 className="text-4xl font-bold text-slate-900 tracking-tight">Kairos</h1>
|
用户名
|
||||||
<p className="text-slate-500 mt-2">初始化你的人生操作系统</p>
|
</label>
|
||||||
</div>
|
<input
|
||||||
|
id="name"
|
||||||
<div className="bg-white rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.04)] ring-1 ring-slate-100 p-8">
|
type="text"
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
required
|
||||||
<div>
|
value={name}
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-slate-700 mb-1.5">
|
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"
|
||||||
</label>
|
placeholder="你的名字"
|
||||||
<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}
|
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]"
|
/>
|
||||||
>
|
</div>
|
||||||
{loading ? '初始化中...' : '开始使用 Kairos'}
|
|
||||||
</button>
|
<div>
|
||||||
</form>
|
<label htmlFor="email" className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||||
</div>
|
邮箱
|
||||||
|
</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>
|
</div>
|
||||||
)
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()),
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user