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:
@@ -55,7 +55,7 @@ src/
|
||||
│ ├── AdminSidebar.tsx # Admin sidebar (reads module registry)
|
||||
│ ├── Error.tsx # Error boundary fallback
|
||||
│ ├── NotFound.tsx # 404 fallback
|
||||
│ └── ui/ # shadcn/ui components (DO NOT manually edit)
|
||||
│ └── ui/ # shadcn/ui components (可自由修改,添加新组件用 bunx shadcn@latest add)
|
||||
├── hooks/
|
||||
│ └── use-mobile.ts
|
||||
├── lib/
|
||||
@@ -286,7 +286,7 @@ export const relations = defineRelations(schema, (r) => ({
|
||||
- Use `useState` callback ref for virtualizer scroll elements inside Dialogs
|
||||
|
||||
**DON'T:**
|
||||
- Manually edit `src/components/ui/*.tsx` (use `bunx shadcn@latest add`)
|
||||
- Add new `src/components/ui/*.tsx` without CLI (use `bunx shadcn@latest add` to scaffold, then freely customize)
|
||||
- Edit `src/routeTree.gen.ts` (auto-generated)
|
||||
- Use `asChild` prop (base-ui uses `render`, NOT Radix)
|
||||
- Import from `drizzle-zod` (use `drizzle-orm/zod`)
|
||||
|
||||
@@ -5,6 +5,6 @@ export default defineConfig({
|
||||
schema: './src/server/db/schema/index.ts',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
url: process.env.DATABASE_URL ?? '',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "bunx --bun vite build",
|
||||
"cli": "bun run src/cli/index.ts",
|
||||
"compile": "bun compile.ts",
|
||||
"compile:darwin": "bun run compile:darwin:arm64 && bun run compile:darwin:x64",
|
||||
"compile:darwin:arm64": "bun compile.ts --target bun-darwin-arm64",
|
||||
@@ -36,11 +37,12 @@
|
||||
"@orpc/zod": "catalog:",
|
||||
"@t3-oss/env-core": "catalog:",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tanstack/react-virtual": "catalog:",
|
||||
"@tanstack/react-router": "catalog:",
|
||||
"@tanstack/react-router-ssr-query": "catalog:",
|
||||
"@tanstack/react-start": "catalog:",
|
||||
"@tanstack/react-virtual": "catalog:",
|
||||
"better-auth": "catalog:",
|
||||
"citty": "catalog:",
|
||||
"class-variance-authority": "catalog:",
|
||||
"clsx": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { hashPassword } from 'better-auth/crypto'
|
||||
import { defineCommand } from 'citty'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { drizzle } from 'drizzle-orm/postgres-js'
|
||||
import * as authSchema from '@/server/auth/schema'
|
||||
|
||||
export const resetPassword = defineCommand({
|
||||
meta: {
|
||||
name: 'reset-password',
|
||||
description: '重置 owner 密码',
|
||||
},
|
||||
args: {
|
||||
password: {
|
||||
type: 'string',
|
||||
description: '新密码(至少 8 个字符)',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
run: async ({ args }) => {
|
||||
const databaseUrl = process.env.DATABASE_URL
|
||||
if (!databaseUrl) {
|
||||
console.error('错误: 未设置 DATABASE_URL 环境变量')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const db = drizzle({ connection: databaseUrl })
|
||||
|
||||
const owner = await db
|
||||
.select({ id: authSchema.user.id, email: authSchema.user.email })
|
||||
.from(authSchema.user)
|
||||
.limit(1)
|
||||
if (owner.length === 0 || !owner[0]) {
|
||||
console.error('错误: 系统尚未初始化,请先通过 Web 界面完成设置')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let newPassword = args.password
|
||||
if (!newPassword) {
|
||||
process.stdout.write('请输入新密码: ')
|
||||
const reader = Bun.stdin.stream().getReader()
|
||||
const chunk = await reader.read()
|
||||
newPassword = new TextDecoder().decode(chunk.value).trim()
|
||||
}
|
||||
|
||||
if (!newPassword || newPassword.length < 8) {
|
||||
console.error('错误: 密码至少需要 8 个字符')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const hash = await hashPassword(newPassword)
|
||||
|
||||
const result = await db
|
||||
.update(authSchema.account)
|
||||
.set({ password: hash })
|
||||
.where(eq(authSchema.account.userId, owner[0].id))
|
||||
.returning({ id: authSchema.account.id })
|
||||
|
||||
if (result.length === 0) {
|
||||
console.error('错误: 未找到凭据账户,请确认 owner 使用邮箱密码注册')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await db.delete(authSchema.session).where(eq(authSchema.session.userId, owner[0].id))
|
||||
|
||||
console.log(`✓ 已重置 ${owner[0].email} 的密码,所有会话已失效`)
|
||||
process.exit(0)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineCommand, runMain } from 'citty'
|
||||
import { resetPassword } from './commands/auth'
|
||||
|
||||
const main = defineCommand({
|
||||
meta: {
|
||||
name: 'kairos',
|
||||
description: 'Kairos 服务端管理工具',
|
||||
},
|
||||
subCommands: {
|
||||
auth: defineCommand({
|
||||
meta: {
|
||||
name: 'auth',
|
||||
description: '认证管理',
|
||||
},
|
||||
subCommands: {
|
||||
'reset-password': resetPassword,
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
runMain(main)
|
||||
@@ -70,7 +70,12 @@ function SidebarProvider({
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
cookieStore.set({
|
||||
name: SIDEBAR_COOKIE_NAME,
|
||||
value: String(openState),
|
||||
path: '/',
|
||||
expires: Date.now() + SIDEBAR_COOKIE_MAX_AGE * 1000,
|
||||
})
|
||||
},
|
||||
[setOpenProp, open],
|
||||
)
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as SignupRouteImport } from './routes/signup'
|
||||
import { Route as SetupRouteImport } from './routes/setup'
|
||||
import { Route as RecoverRouteImport } from './routes/recover'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as ProtectedRouteImport } from './routes/_protected'
|
||||
import { Route as ProtectedIndexRouteImport } from './routes/_protected/index'
|
||||
@@ -21,9 +22,14 @@ import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc.$'
|
||||
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth.$'
|
||||
import { Route as ProtectedAdminBookmarksRouteImport } from './routes/_protected/admin/bookmarks'
|
||||
|
||||
const SignupRoute = SignupRouteImport.update({
|
||||
id: '/signup',
|
||||
path: '/signup',
|
||||
const SetupRoute = SetupRouteImport.update({
|
||||
id: '/setup',
|
||||
path: '/setup',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const RecoverRoute = RecoverRouteImport.update({
|
||||
id: '/recover',
|
||||
path: '/recover',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const LoginRoute = LoginRouteImport.update({
|
||||
@@ -79,7 +85,8 @@ const ProtectedAdminBookmarksRoute = ProtectedAdminBookmarksRouteImport.update({
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof ProtectedIndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/signup': typeof SignupRoute
|
||||
'/recover': typeof RecoverRoute
|
||||
'/setup': typeof SetupRoute
|
||||
'/admin': typeof ProtectedAdminRouteWithChildren
|
||||
'/api/$': typeof ApiSplatRoute
|
||||
'/api/health': typeof ApiHealthRoute
|
||||
@@ -90,7 +97,8 @@ export interface FileRoutesByFullPath {
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/login': typeof LoginRoute
|
||||
'/signup': typeof SignupRoute
|
||||
'/recover': typeof RecoverRoute
|
||||
'/setup': typeof SetupRoute
|
||||
'/api/$': typeof ApiSplatRoute
|
||||
'/api/health': typeof ApiHealthRoute
|
||||
'/': typeof ProtectedIndexRoute
|
||||
@@ -103,7 +111,8 @@ export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/_protected': typeof ProtectedRouteWithChildren
|
||||
'/login': typeof LoginRoute
|
||||
'/signup': typeof SignupRoute
|
||||
'/recover': typeof RecoverRoute
|
||||
'/setup': typeof SetupRoute
|
||||
'/_protected/admin': typeof ProtectedAdminRouteWithChildren
|
||||
'/api/$': typeof ApiSplatRoute
|
||||
'/api/health': typeof ApiHealthRoute
|
||||
@@ -118,7 +127,8 @@ export interface FileRouteTypes {
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/login'
|
||||
| '/signup'
|
||||
| '/recover'
|
||||
| '/setup'
|
||||
| '/admin'
|
||||
| '/api/$'
|
||||
| '/api/health'
|
||||
@@ -129,7 +139,8 @@ export interface FileRouteTypes {
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/login'
|
||||
| '/signup'
|
||||
| '/recover'
|
||||
| '/setup'
|
||||
| '/api/$'
|
||||
| '/api/health'
|
||||
| '/'
|
||||
@@ -141,7 +152,8 @@ export interface FileRouteTypes {
|
||||
| '__root__'
|
||||
| '/_protected'
|
||||
| '/login'
|
||||
| '/signup'
|
||||
| '/recover'
|
||||
| '/setup'
|
||||
| '/_protected/admin'
|
||||
| '/api/$'
|
||||
| '/api/health'
|
||||
@@ -155,7 +167,8 @@ export interface FileRouteTypes {
|
||||
export interface RootRouteChildren {
|
||||
ProtectedRoute: typeof ProtectedRouteWithChildren
|
||||
LoginRoute: typeof LoginRoute
|
||||
SignupRoute: typeof SignupRoute
|
||||
RecoverRoute: typeof RecoverRoute
|
||||
SetupRoute: typeof SetupRoute
|
||||
ApiSplatRoute: typeof ApiSplatRoute
|
||||
ApiHealthRoute: typeof ApiHealthRoute
|
||||
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
||||
@@ -164,11 +177,18 @@ export interface RootRouteChildren {
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/signup': {
|
||||
id: '/signup'
|
||||
path: '/signup'
|
||||
fullPath: '/signup'
|
||||
preLoaderRoute: typeof SignupRouteImport
|
||||
'/setup': {
|
||||
id: '/setup'
|
||||
path: '/setup'
|
||||
fullPath: '/setup'
|
||||
preLoaderRoute: typeof SetupRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/recover': {
|
||||
id: '/recover'
|
||||
path: '/recover'
|
||||
fullPath: '/recover'
|
||||
preLoaderRoute: typeof RecoverRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/login': {
|
||||
@@ -275,7 +295,8 @@ const ProtectedRouteWithChildren = ProtectedRoute._addFileChildren(
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
ProtectedRoute: ProtectedRouteWithChildren,
|
||||
LoginRoute: LoginRoute,
|
||||
SignupRoute: SignupRoute,
|
||||
RecoverRoute: RecoverRoute,
|
||||
SetupRoute: SetupRoute,
|
||||
ApiSplatRoute: ApiSplatRoute,
|
||||
ApiHealthRoute: ApiHealthRoute,
|
||||
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { authClient } from '@/server/auth/client'
|
||||
import { getSession } from '@/server/auth/functions'
|
||||
import { checkInitialized, getSession } from '@/server/auth/functions'
|
||||
|
||||
export const Route = createFileRoute('/login' as never)({
|
||||
beforeLoad: async () => {
|
||||
const initialized = await checkInitialized()
|
||||
if (!initialized) {
|
||||
throw redirect({ to: '/setup' as never })
|
||||
}
|
||||
const session = await getSession()
|
||||
if (session) {
|
||||
throw redirect({ to: '/' as never })
|
||||
@@ -93,9 +97,8 @@ function LoginPage() {
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-slate-500 mt-6">
|
||||
还没有账号?{' '}
|
||||
<Link to={'/signup' as never} className="text-indigo-600 hover:text-indigo-700 font-medium">
|
||||
注册
|
||||
<Link to={'/recover' as never} className="text-indigo-600 hover:text-indigo-700 font-medium">
|
||||
无法登录?使用恢复密钥
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
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,19 +1,19 @@
|
||||
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
|
||||
import { createFileRoute, redirect, useRouter } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { authClient } from '@/server/auth/client'
|
||||
import { getSession } from '@/server/auth/functions'
|
||||
import { checkInitialized, completeSetup } from '@/server/auth/functions'
|
||||
|
||||
export const Route = createFileRoute('/signup' as never)({
|
||||
export const Route = createFileRoute('/setup' as never)({
|
||||
beforeLoad: async () => {
|
||||
const session = await getSession()
|
||||
if (session) {
|
||||
throw redirect({ to: '/' as never })
|
||||
const initialized = await checkInitialized()
|
||||
if (initialized) {
|
||||
throw redirect({ to: '/login' as never })
|
||||
}
|
||||
},
|
||||
component: SignupPage,
|
||||
component: SetupPage,
|
||||
})
|
||||
|
||||
function SignupPage() {
|
||||
function SetupPage() {
|
||||
const router = useRouter()
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
@@ -21,6 +21,8 @@ function SignupPage() {
|
||||
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()
|
||||
@@ -45,20 +47,76 @@ function SignupPage() {
|
||||
})
|
||||
|
||||
if (signUpError) {
|
||||
setError(signUpError.message ?? '注册失败,请重试')
|
||||
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>
|
||||
<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">
|
||||
@@ -134,16 +192,9 @@ function SignupPage() {
|
||||
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 ? '注册中...' : '注册'}
|
||||
{loading ? '初始化中...' : '开始使用 Kairos'}
|
||||
</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,8 +1,87 @@
|
||||
import { createServerFn } from '@tanstack/react-start'
|
||||
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 * as authSchema from '@/server/auth/schema'
|
||||
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 () => {
|
||||
const headers = getRequestHeaders()
|
||||
return await auth.api.getSession({ headers })
|
||||
})
|
||||
|
||||
export const checkInitialized = createServerFn({ method: 'GET' }).handler(async () => {
|
||||
const users = await db.select({ id: authSchema.user.id }).from(authSchema.user).limit(1)
|
||||
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,5 +1,6 @@
|
||||
import { betterAuth } from 'better-auth'
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
||||
import { APIError } from 'better-auth/api'
|
||||
import { tanstackStartCookies } from 'better-auth/tanstack-start'
|
||||
import { env } from '@/env'
|
||||
import * as authSchema from '@/server/auth/schema'
|
||||
@@ -21,5 +22,17 @@ export const auth = betterAuth({
|
||||
maxAge: 5 * 60,
|
||||
},
|
||||
},
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
before: async () => {
|
||||
const existingUsers = await db.select({ id: authSchema.user.id }).from(authSchema.user).limit(1)
|
||||
if (existingUsers.length > 0) {
|
||||
throw new APIError('FORBIDDEN', { message: 'System already has an owner. Registration is disabled.' })
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [tanstackStartCookies()],
|
||||
})
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from '../../../modules/bookmarks/schema'
|
||||
export * from '../../auth/schema'
|
||||
export * from './system'
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
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()),
|
||||
})
|
||||
@@ -32,6 +32,7 @@
|
||||
"@tanstack/react-start": "catalog:",
|
||||
"@tanstack/react-virtual": "catalog:",
|
||||
"better-auth": "catalog:",
|
||||
"citty": "catalog:",
|
||||
"class-variance-authority": "catalog:",
|
||||
"clsx": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
@@ -96,6 +97,7 @@
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"better-auth": "^1.2.8",
|
||||
"citty": "^0.2.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-kit": "1.0.0-beta.15-859cf75",
|
||||
@@ -619,6 +621,8 @@
|
||||
|
||||
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
|
||||
"citty": ["citty@0.2.1", "", {}, "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg=="],
|
||||
|
||||
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
+2
-1
@@ -66,6 +66,7 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0"
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"citty": "^0.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user